/Unit Testing, JavaScript, Jest

Jest | 跨越同步執行的 Jest 測試

前言

上一篇「讓 Jest 為你的 Code 做測試-基礎用法教學」中提到了如何使用 Jest 做單元測試,但是 JavaScript 是屬於同步執行的程式碼,這種特性會使 Jest 在測試結果出現問題,本篇會針對這點來講解關於異步測試的方法。


異步測試

測試流程

首先建立一個用 setTimeout 的延遲模擬請求獲取資料的 function ,獲取後再將資料傳到 callBack 中執行。

這裡將函式 fetchData 放在 ./func/async.js 中:

//傳入一個 callBack 函數,在獲取資料時執行
const fetchData = (callBack) => {
    setTimeout(() => { 
        callBack('getData') 
    }, 3000)
}

//將該 fetchData 函式匯出
module.exports = {
    fetchData: fetchData
}

建立測試檔案 ./test/async.test.js ,在檔案中匯入 fetchData ,並在 callBack 函式內設定斷言,測試 fetchData 回傳的資料是否符合期望值:

let async = require('../funcs/async.js')

//建立測試
test('test async', () => {]
    //callBack 會在 fetchData 取得資料後執行
    const callBack = (data) => {
        expect(data).toBe('getData')
    }
    
    //將上方的 callBack 函式傳入 fetchData 中
    async.fetchData(callBack)
})

完成以上設置後,便可以執行測試了,這裡使用能夠產生覆蓋率報告的測試指令,會得到以下結果:

測試結果顯示正確,但覆蓋率卻不是 100 %

可以看到結果是 PASS ,也就是說 callBack 接收到的斷言和我們期望的值相同,但是卻發現測試的覆蓋率卻不是 100 %,代表在 fetchData 中有些地方沒有執行到,測試就已經結束了,這時可以點開 ./coverage/Icov-report 內關於 async.js 的執行報告來看:

在測試時根本沒跑進 callBack 中

發現問題

經過上方的操作,可以發現就算在測試中 JavaScript 也是一如既往同步執行,不會等到 callBack 執行,也不會到設定的斷言,整個測試就已經結束了。

這裡可以將代碼改為更直接的方式測試:

let async = require('../funcs/async.js')

test('test async', () => {
    const callBack = (data) => {
        //將斷言中的結果傳入空值,期望值為 'getData' 不變
        expect('').toBe('getData')
    }
    async.fetchData(callBack)
})

有興趣可以試試上方的測試,結果依然會是 PASS ,因為 callBack 函式根本就沒有執行到。

解決問題

雖然在 JavaScript 中,處理同步問題一直不是輕鬆的事情,但是 Jest 在執行測試的時候可以透過 done() 來應付這個狀況。

簡單來說,如果在測試裡有加入 done() ,那只要還沒執行到 done() 就不算結束測試,因此可以將它加入上方的程式裡:

let async = require('../funcs/async.js')

//將 done 傳入測試中
test('test async', (done) => {
    const callBack = (data) => {
        expect(data).toBe('getData')

        //在 callBack 函式內的斷言後加上 done
        done()
    }
    async.fetchData(callBack)
})

加入 done() 後重新執行測試,就可以看到報告呈現了完美的一片綠光。

測試覆蓋率已達 100 %

需要注意的是,如果測試中有傳入 done 但卻未執行它,那麼該測試結果就會出現 FAIL 失敗:

let async = require('../funcs/async.js')

test('test async', (done) => {
    const callBack = (data) => {
        expect(data).toBe('getData')
    }
    async.fetchData(callBack)
})

執行測試結果如下:

測試時擁有參數 done 卻未執行,測試會產生錯誤

使用 Promise

如果既有的程式碼已使用 Promise 處理同步,便不必再使用 done ,直接以 .then 接收 Promise 物件傳進 resolve 的結果即可:

const promiseFetchData = () =>{
    return new Promise((resolve,reject)=>{
        setTimeout(()=>{
            resolve('getData')
        }, 3000)
    }) 
}

module.exports = {
    promiseFetchData: promiseFetchData
}
let async = require('../funcs/async.js')

test('test promise async', ()=>{
    //return 是必須的,否則不會執行 .then 的內容
    return async.promiseFetchData()
        .then((data)=>{
            expect(data).toBe('getData')
        })
})

需要注意的是,在測試的 function 中必須加上 return ,否則測試不會跑進 .then 中,只會在 Promise 將結果送進 resolve 時就結束了,另外!當 Promise 中的結果跑進 reject 那測試也會產生錯誤。

測試結果得到 PASS ,覆蓋率也是 100 %:

使用 Promise 測試的結果

上述說明了成功的 resolve 在測試中用 .then 接收,而失敗的 reject 則是使用 .catch 處理接下來的動作,這部分和 Promise 的操作都相同,就不再闡述,如果對 Promise 不熟,可以參考「JacaScript | 從Promise開始承諾的部落格生活」。

Jestexpect 內還另外擁有兩個內建屬性來針對 Promise 做處理,分別為 resolvesrejects ,他們會直接捕捉 Promise 傳進resolvereject 的資料判斷是否符合期望值,這裡以 rejects 作為例子:

const promiseErrorFetchData = () =>{
    return new Promise((resolve,reject)=>{
        setTimeout(()=>{
            reject('error')
        }, 3000)
    }) 
}

module.exports = {
    promiseErrorFetchData: promiseErrorFetchData
}

測試時,直接將 promiseErrorFetchData 作為參數傳遞給 expect ,並透過 expect 的內建屬性 rejects 取得 promiseErrorFetchDataPromise 傳進 reject 的資料下斷言:

test('test rejects in promise',()=>{
    //return 是必須的,否則不會等 Promise 執行到 reject 測試就結束了
    return expect(async.promiseErrorFetchData()).rejects.toBe('error')
})

這個方式筆者認為能夠更直覺性的使用 Promise 的測試,得到的結果也都和上方一樣,但主要還是配合團隊使用其中一種,才不會讓測試代碼顯得雜亂,當然!個人開發也是一樣。


本文針對異步請求在測試內產生的問題舉了幾個例子解決,但其實官網上還有提出一種使用 async/await 的方式,不過筆者還未學習到 JavaScript 中關於 asyncawait 的用法,因此怕現階段會誤導大家,就等日後使用到再回來補充文章內容。

如果文章中有任何問題,或是不理解的地方,都可以留言告訴我!謝謝大家!

參考文章

  1. https://jestjs.io/docs/en/asynchronous#async-await