今天 「理解 Mock 基礎概念」 即將告一個段落,目前為止我們 mock function 大致上滿 OK 了,但未來有可能會遇上一個問題:
前一天使用的 spyOn
仍是 monkey patching 的一種形式,這只能用在 common JS 的情況,但如果是 ESModule 的話,這樣的 monkey patching 就不管用了。
未來想要避免這個的問題的話,可以使用 Jest 的 jest.mock API。jest.mock
的第一個參數接收的是想要 mock 的 module 路徑 (jest.mock
被呼叫時的相對路徑 )。第二個參數接收一個「工廠模式」的 function,這個 function 回傳這個 module 的 mock。
現在在 __test__
資料夾新建 inline-module-mock.js
檔案,我們延續昨天寫的測試,在這個檔案內用 jest.mock
改寫。
jest.mock
第一個參數傳入 '../utils'
,第二個參數傳入一個 function,這個 function 會回傳一個 object,object 有一個 getWinner
method,是一個 mock function:
test/inline-module-mock.js
jest.mock('../utils', () => {
return {
getWinner: jest.fn((p1, p2) => p1)
}
})
要復原 mock function 的話,可以用 mockReset() 這個 API,這邊已經不需要 jest.spyOn()
,可以刪除:
test/inline-module-mock.js
const thumbWar = require('../thumb-war')
const utilsMock = require('../utils')
jest.mock('../utils', () => {
return {
getWinner: jest.fn((p1, p2) => p1)
}
})
test('returns winner', () => {
// 刪除 jest.spyOn(utils, 'getWinner')
// 刪除 utils.getWinner.mockImplementation((p1, p2) => p2)
const winner = thumbWar('小夫', '胖虎')
expect(winner).toBe('小夫')
expect(utilsMock.getWinner.mock.calls).toEqual([
['小夫', '胖虎'],
['小夫', '胖虎']
])
// 刪除 utils.getWinner.mockRestore()
utils.getWinner.mockReset() // 改用 mockReset
})
到這邊,我們完成了用 jest.mock
來 mock 整個 module。
為了更理解工具的原理,現在我們來試試不用 Jest,自己做一個跟 jest.mock
差不多的功能。
jest.mock
可以運作是因為它掌握了整個 module system,我們現在使用 require.cache 來模仿做差不多的事,達成 mock 整個 module 的目的。
require.cache
是一個 object,當 module 被 require 的時候,會 cache 一份在這個 object。object 的 key 是這個 module 的路徑,value 是一個 Module
object。可以在 node 環境 console.log(require.cache)
看看:
Console Output
{ '/Users/Shane/js-mocking/module-sample.js':
Module {
id: '.',
exports: {},
parent: null,
...
}
}
node 會先檢查這份 cache,拿這份 cache 裡的東西。所以,我們的思路會是:
在 require.cache
裡新增我們想要 mock 的 module 的快取,讓測試檔案 require 時拿到的是 mock 的 module ,最後,再將這個 cache 清除掉,才不會影響到其他有用到這個 module 的功能。
注意要在 modules 被 require 之前,初始
require.cache
,好讓我們後面取得的是被 mock 的utils
module。
現在在 no-framework
資料夾新建一個檔案叫做 inline-module-mock.js
,改寫昨日寫的測試。
首先,用 require.resolve 取到 module 的路徑名稱,賦值給 utilsPath
。然後新增一個 object 給 require.cache[utilsPath]
,這個 object 中有一個叫 exports
的 key,就是我們的 mock:
no-framework/inline-module-mock.js
const utilsPath = require.resolve('../utils')
require.cache[utilsPath] = {
id: utilsPath,
filename: utilsPath,
loaded: true,
exports: {
getWinner: fn((p1, p2) => p1) // mock function
}
}
記得將 fn()
移到上面,然後,可以刪掉 spyOn
,已經不需要它了,也一樣刪掉 getWinner.mockImplementation
相關的 code :
no-framework/inline-module-mock.js
// 將 fn 移到上面
function fn(impl = () => {}) {
const mockFn = (...args) => {
mockFn.mock.calls.push(args)
return impl(...args)
}
mockFn.mock = {calls: []}
// 刪除 mockFn.mockImplementation = newImpl => (impl = newImpl)
return mockFn
}
// 在這裡初始 require.cache
const utilsPath = require.resolve('../utils')
require.cache[utilsPath] = {
id: utilsPath,
filename: utilsPath,
loaded: true,
exports: {
getWinner: fn((p1, p2) => p1) // mock function
}
}
const assert = require('assert')
const thumbWar = require('../thumb-war')
const utils = require('../utils')
// 刪除 spyOn 跟 mockImplementation
/*
function spyOn(obj, prop) {
...
}
spyOn(utils, 'getWinner')
utils.getWinner.mockImplementation((p1, p2) => p1)
*/
最後,我們要復原回原本的 module,就是刪掉這個 module 的 cache 而已:
no-framework/inline-module-mock.js
delete require.cache[utilsPath]
我們不用 Jest 所寫的完整測試檔案,如下:
no-framework/inline-module-mock.js
function fn(impl = () => {}) {
const mockFn = (...args) => {
mockFn.mock.calls.push(args)
return impl(...args)
}
mockFn.mock = {calls: []}
return mockFn
}
const utilsPath = require.resolve('../utils')
require.cache[utilsPath] = {
id: utilsPath,
filename: utilsPath,
loaded: true,
exports: {
getWinner: fn((p1, p2) => p1)
}
}
const assert = require('assert')
const thumbWar = require('../thumb-war')
const utils = require('../utils')
const winner = thumbWar('小夫', '胖虎')
assert.strictEqual(winner, '小夫')
assert.deepStrictEqual(utils.getWinner.mock.calls, [
['小夫', '胖虎'],
['小夫', '胖虎']
])
// 復原 module
delete require.cache[utilsPath]
當我們自己模仿 Jest 寫 mock module 功能的時候,需要在 modules 被 require 之前,初始 require.cache
。但有趣的是:使用 Jest 的話,我們不一定要將 jest.mock
放在 require 之前。因為 Jest 會確保測試時 require 拿到的是 mock 的 module。在 Jest 執行測試之前,它會自己將 jest.mock
移到檔案的最上方 (hoist),確保在任何 module 載入完成之前,mock module 已存在。另外,jest.mock
的 hoist 對我們在使用 ESModules 的情況是非常有幫助的,因為 ESModules 的 import 是具有 hoist 特性的。
目前我們只有測試 thumbWar
module 時會用到 utils
的 mock module,但如果有其他測試需要用到 utils
這個 mock module 呢?使用 Jest 測試時,我們可以建立一個資料夾 __mocks__
,裡面放需要共用的 mock module。
以現在的 utils
為例, __mocks__
資料夾裡新建一個檔案 utils.js
,然後 module.exports
我們要用到的 mock:
mock/utils.js
module.exports = {
getWinner: jest.fn((p1, p2) => p1)
}
回到測試檔案,現在 jest.mock
只需要傳入第一個參數,也就是需要被 mock 的 module 路徑,Jest 會自動抓取我們剛剛在 __mocks__
建立的 mock 檔案:
jest.mock('../utils')
現在測試檔案會長這樣:
test/external-mock-module.js
const thumbWar = require('../thumb-war')
const utilsMock = require('../utils')
jest.mock('../utils') // Jest 會自動去抓 utils 的 mock
test('returns winner', () => {
const winner = thumbWar('小夫', '胖虎')
expect(winner).toBe('小夫')
expect(utilsMock.getWinner.mock.calls).toEqual([
['小夫', '胖虎'],
['小夫', '胖虎']
])
utilsMock.getWinner.mockReset()
})
為了更理解工具的原理,現在我們來試試不用 Jest,自己做一個跟 __mocks__
共用 mock 差不多的功能。
建立一個 __no-framework-mocks__
資料夾,裡面有 utils.js
檔案,在這個檔案裡放入我們需要的內容,並 module.exports
mock:
no-framework-mocks/utils.js
function fn(impl = () => {}) {
const mockFn = (...args) => {
mockFn.mock.calls.push(args)
return impl(...args)
}
mockFn.mock = {calls: []}
return mockFn
}
module.exports = {
getWinner: fn((p1, p2) => p1)
}
回到測試檔案,現在的思路會是:
初始剛剛建立的 __no-framework-mocks__/utils.js
的 cache,再將 require.cache[utilsPath]
指向這個 cache。
no-framework/external-mock-module.js
require('../__no-framework-mocks__/utils') // 初始 __no-framework-mocks__/utils.js 的快取
const utilsPath = require.resolve('../utils')
const mockUtilsPath = require.resolve('../__no-framework-mocks__/utils') // 取得 mock module 路徑
require.cache[utilsPath] = require.cache[mockUtilsPath] // 將 require.cache[utilsPath] 指向 mock module 的 cache
我們不用 Jest 所寫的完整測試檔案,如下:
no-framework/external-mock-module.js
require('../__no-framework-mocks__/utils')
const utilsPath = require.resolve('../utils')
const mockUtilsPath = require.resolve('../__no-framework-mocks__/utils')
require.cache[utilsPath] = require.cache[mockUtilsPath]
const assert = require('assert')
const thumbWar = require('../thumb-war')
const utils = require('../utils')
const winner = thumbWar('小夫', '胖虎')
assert.strictEqual(winner, '小夫')
assert.deepStrictEqual(utils.getWinner.mock.calls, [
['小夫', '胖虎'],
['小夫', '胖虎']
])
delete require.cache[utilsPath]
目前 「理解 Mock 基礎概念」 已告一個段落,為了考慮 ESModule 的情況,今天我們學習用 jest.mock
來 mock 整個 module,用 mockReset()
API 來復原 module,並用 __mocks__
建立可以共享的 mock module。
最後,自己動手造輪子的部分,模擬的並非都是 Jest 確切做的事情,因為 Jest 實際上在執行我們的測試時,掌握了整個 module system。因此,這裡的重點只在於幫助我們建立觀念,理解工具的原理。