Jest | JOJO是你?我的替身能力是 Mock !

神Q超人
Enjoy life enjoy coding
10 min readMar 31, 2019
取自 JoJo 的奇幻冒險 星塵鬥士篇 03

前言

Mock 在 Unit Test 中扮演著很重要的角色,因為單元測試必須將關注點放在要被測試的 Function 身上,不能讓不確定性在 Function 外的地方發生,因此要使用 Mock 迴避掉不確定性,讓焦點集中在受測試的 Function 上,文章中會再舉一些明確的使用例子。

Mock 替身函式

A:欸欸!你等等可別出錯餒,這場戲主角是我!很重要的!

B:不行啦!這場戲重點是你,如果因為我出錯造成失敗就太沒意義了!

A:那你有什麼好方法嗎?沒有你這場戲也演不下去!

B:不然交給 Mock 來當替身好了!他絕對能夠好好扮演我的角色,而且絕對不會出錯!

沒錯!Mock 替身函式,也被稱為 Test Double 作用就如同字面上的意思,當各位在測試某個 Function 的時候,多少會遇到需要依賴其他 Model 或 Function 的狀況, Mock 是為了讓 Unit Test 能夠讓過程專注在要測試的 Function 上,讓測試的邏輯性不會因為依賴造成不確定性,而作為依賴 Function 的替身進行測試。

在使用 Mock 前,可以先看一下下方的例子:

這個測試沒有什麼問題,單純斷言計算金額是否等於期望值。但若是在計算時還要再呼叫另一個 Function 判斷該產品是否有折扣呢?上方的 calculateThePrice 會被更改為:

計算總價的時候,會去執行傳入 calculateThePricecheckDiscount 判斷該商品是否有折扣,這種情況就能說 calculateThePrice 依賴著 checkDiscount

專有名詞上,被測試的函式 calculateThePrice 稱為 SUT ( System Under Test 測試目標), checkDiscount 被稱為 SUT 的 DOC ( Depended-on Component 依賴組件)。

雖然這樣還是可以做測試,但如同前言中說明的,在測試時應該將關注點放在 SUT 上, DOC 中做了什麼與 SUT 一點關係都沒有,只需要曉得當 checkDiscount 回傳 truefalse 時, calculateThePrice 所處理的邏輯是否正確,不能也不會因 DOC 成為影響 SUT 執行結果的因素,為了減少 SUT 中的依賴就會需要使用 Mock 。

jest.fn()

建立一個 Mock 最基本的方式就是 jest.fn() ,在初始狀態下,這個 Mock 會在呼叫時回傳 undefined

const mockFunction = jest.fn()// 回傳 undefined
console.log(mockFunction())

但是使用上會需要賦予基本的回傳值,才能夠讓 SUT 正常執行,因此需要在 Mock 上使用 mockReturnValueOnce 或是 mockReturnValue 指定,前者為只回傳一次,後者為永久回傳該值:

// 指定一次回傳值
mockFunction.mockReturnValueOnce(true)
// 第一次回傳 true
mockFunction()
// 第二次回傳 undefined
mockFunction()
// 指定永久回傳值
mockFunction.mockReturnValue(true)
// 第一次回傳 true
mockFunction()
// 不論呼叫幾次都是回傳 true
mockFunction()

另外 mockReturnValueOncemockReturnValue 也可以連接使用,能夠測試 SUT 中的各種邏輯狀況:

mockFunction
.mockReturnValueOnce(true)
.mockReturnValueOnce(false)
.mockReturnValue(true)
// 第一次回傳 true
mockFunction()
// 第二次回傳 false
mockFunction()
// 第三次後回傳 true
mockFunction()

那現在,將 Mock 函數加進上方的例子中,取代傳入 SUT 中的 DOC :

測試結果如下:

以 Mock 代替 DOC 做測試,就能確保只要邏輯有錯責任就一定是在 SUT 身上,因此 Unit Test 的主要精神:「為每個最小單位做測試」, Mock 可以說是貫徹這個理念的最佳 MVP 。

Mock 的執行狀態

Mock 在測試時除了能夠代替 DOC 做回傳的功能外,也能夠對 Mock 的執行狀態做斷言。

繼續舉上方的測試來說,如果 calculateThePrice 的邏輯性正確,所有產品都必須確認是否有折扣,那 Mock 就一定會執行兩次,又或是在判斷折扣時, Mock 是否有接收到正確的產品名稱等等,都可以從 Mock 上做斷言,進行更完整的測試:

上方新增了兩個對 Mock 的斷言,都是從 Mock 中的 calls 取出一個二維陣列:

console.log(mockCheckDiscount.mock.calls)
//會得到 [ ['milk'], ['apple'] ]

第一個維度會儲存每次執行的資料,第二個維度為 Mock 執行時接收到的所有參數,例如用 calls.length 得到執行次數,或者是以 calls[0][0] 取得第一次執行時接收到的第一個參數,測試結果也不會讓人失望:

除了上面的使用方法外, jest.fn() 也能夠賦予簡單的邏輯,以便回傳不同的值:

// 將一個簡單的 Function 指定給 jest.fn 的第一個引數
const returnDoubleMock = jest.fn((x) => {
return x * 2
})
// 執行時會依照該 Function 的邏輯回傳值
returnDoubleMock(5) // 回傳 10
returnDoubleMock(3) // 回傳 6

雖然送入邏輯就能讓 DOC 做到更複雜的回傳值提供給 SUT 做測試,但是需要注意的是,有邏輯就有可能會出錯,因此在為 Mock 賦予邏輯時,還是得小心一些,畢竟應該不會有人想為了測試 Mock 的邏輯性而寫測試吧?

jest.mock()

如果說 jest.fn 能夠作為一個 Function 的替身,那麼 jest.mock 就是能模擬整個模組的 Mock!假設在 SUT 中需使用到 axios 框架中的 get Function 來獲取數據,但直接去做 Request 又會讓測試的成本太高或網路及其他外在因素影響測試結果。

但是所謂的單元測試,就是只要想便能測試,不需也不能再測試時受到任何限制,因此面對上方的狀況就能使用 jest.mock() 模擬 axios 這個框架,並以 mockResolveValue 設定axios.get 的回傳結果,這麼一來就不會真的執行到 axios.get 而是透過 Mock 回傳設定的結果而已。

對回傳的 Promise 做斷言可以參考:Jest | 跨越同步執行的 Jest 測試 這篇文章

除此之外,經過模擬出來的 Function ,被使用時都會記錄執行狀況, jest.mock() 也不例外:

// 值會是 [ ['url/allGoods'] ]
// 代表執行一次,那次接收到的參數為 'url/allGoods'
console.log(axios.get.mock.calls)

jest.spyOn()

最後要介紹的是 jest.spyOn() ,在能夠重現 DOC 邏輯的狀況下就可以使用它,記得上方提過 jest.mock() 可以創建一個假的 Function 和回傳假的資料以便進行測試,所以當為某個 DOC 創建一個 jest.mock() 時,不論真正的 DOC 的邏輯是否有修改過, SUT 的測試永遠都會正確,因為 jest.mock() 不會真正重現 DOC 的邏輯,而是直接回傳一個設定好的資料,這麼一來就會產生測試沒任何意義的「非法測試」。

但是! jest.spyOn() 真的會執行,所以在能夠重現 DOC 的邏輯下,就盡量避免使用 jest.mock()

其實 jest.spyOn() 在使用上為 jest.fn() 的語法糖,記得介紹 jest.fn() 的時候有提到能夠直接給它 Function ,再依該 Function 的邏輯回傳內容給 SUT 對吧! jest.spyOn() 就是包裝了 DOC 的邏輯產生的 Mock ,這裡再回到第一個例子並使用 jest.spyOn() 包裝 checkDiscount 的邏輯:

需要注意的是使用 jest.spyOn() 重現邏輯時,就得把 Function 包進物件或模組中才行,因此上方讓 checkDiscount 成為 productModule 的 Method ,且在 checkDiscount 中還特別加入了 console.log ,這樣進行測試時就能看到 jest.spyOn 是真的執行了 checkDiscount 中的內容:

想比之下,如果直接為 spyCheckDiscount 設定假的回傳值,就不會有 console.log 被印出,因為它變得只會依照設定的資料回傳給 SUT 而已,不會真的去執行,即使測試結果仍然正確:

測試時直接設定假資料回傳
沒有 console.log 被印出,因為 DOC 的邏輯沒有被重現

那問題來了,既然兩者都可以成功,有沒有重現邏輯又如何?

問題在 DOC 如果是自己的,也就是如同上方例子中的 checkDiscount ,在開發時,隨時有可能會更動到他,甚至更改裡面的邏輯,又或是回傳值變了,想想在這些情況發生時,只會傳送預設假資料的 jest.mock() 能夠同時修改嗎?答案是不行!因此在沒注意到的情況下, SUT 正享受著假資料帶來的美好,上線時卻會有「什麼!這傢伙怎麼會回傳這種東西?測試明明就沒有錯!」之類的慘劇發生。

但是 jest.spyOn() 不同,它會去重現 DOC 的邏輯,即使在任何時候修改了它的任何內容都一樣, SUT 在測試時便會發現 DOC 已經和之前測試時有所不同,不論是邏輯、回傳值等等,這時候捕獲了真實資料的測試結果才會有效。

jest.mock() 整個那麼假,可以用在哪裡?用在模擬無法重現邏輯、或者根本不會去變動的第三方套件時,畢竟使用 jest.spyOn() 的原因就是怕改變了 DOC ,但在測試時 SUT 沒有發現它變了,那既然不會去改變那些第三方套件,直接使用 jest.mock() 也沒問題!

本文介紹了 Mock 的三種形式( jest.fn() / jest.mock() / jest.spyOn()),一開始可能會搞不太清楚他們各是在做什麼的,但是只要了解三種 Mock 各自的特性,在測試上一定會有「這時候也許可以用它?」的想法出現!如果對於文章中講解的 Mock 有任何不清楚或是覺得有需要補充的地方,再麻煩留言指教,謝謝!

參考文章

  1. http://teddy-chen-tw.blogspot.com/2014/09/test-double1.html
  2. https://jestjs.io/docs/en/jest-object
  3. https://jestjs.io/docs/en/mock-functions

--

--