iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 9
1
Modern Web

循序漸進學習 Javascript 測試系列 第 9

Day9 理解 Mock 基礎概念:mock 整個 module 及共用 mock module

用 jest.mock 來 mock 整個 module

今天 「理解 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 的 hoist 特性

當我們自己模仿 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 特性的。

讓 mock module 可以共用

目前我們只有測試 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。因此,這裡的重點只在於幫助我們建立觀念,理解工具的原理。


上一篇
Day8 理解 Mock 基礎概念:使用 jest.spyOn 復原被 mock 的 function
下一篇
Day10 實戰 Jest 配置:準備篇
系列文
循序漸進學習 Javascript 測試30

尚未有邦友留言

立即登入留言