到底啥是JavaScript Mock

妖僧風月發表於2019-02-27

原文:But really, what is a JavaScript mock?

By Ken C. Dodds

刪減了前幾段吹牛逼的內容,直接進入正題

第0步

要想知道mock是啥,首先得有東西讓你去測、去mock,下面是我們要測試的程式碼:

import {getWinner} from `./utils`
function thumbWar(player1, player2) {
  const numberToWin = 2
  let player1Wins = 0
  let player2Wins = 0
  while (player1Wins < numberToWin && player2Wins < numberToWin) {
    const winner = getWinner(player1, player2)
    if (winner === player1) {
      player1Wins++
    } else if (winner === player2) {
      player2Wins++
    }
  }
  return player1Wins > player2Wins ? player1 : player2
}
export default thumbWar
複製程式碼

這是一個猜拳遊戲,三局兩勝。從utils庫中使用了一個叫getWinner的函式。這個函式返回獲勝的人,如果是平局則返回null。我們假設getWinner是呼叫了某個第三方的機器學習服務,也就是說我們的測試環境無法控制它,所以我們需要在測試中mock一下。這是一種你只能通過mock才能可靠地測試你的程式碼的情景。(這裡為了簡化,假設這個函式是同步的)

另外,除了重新實現一遍getWinner的邏輯,我們實際上不太可能做出有用的判斷以確定猜拳遊戲中到底是誰獲勝了。所以,沒有mocking的情況下,下面就是我們能給出的最好的測試了:

譯註:沒有mocking的情況下,只能斷言獲勝的選手是參賽選手的一個,這幾乎沒什麼用

import thumbWar from `../thumb-war`
test(`returns winner`, () => {
  const winner = thumbWar(`Ken Wheeler`, `Kent C. Dodds`)
  expect([`Ken Wheeler`, `Kent C. Dodds`].includes(winner)).toBe(true)
})
複製程式碼

第1步

Mocking最簡單的形式是一種稱作猴子補丁(Monkey-patching)的形式。下面給出一個例子:

譯註:猴子補丁是指在本地修改引入的程式碼,但是隻能對當前執行的例項有影響。

import thumbWar from `../thumb-war`
import * as utils from `../utils`
test(`returns winner`, () => {
  const originalGetWinner = utils.getWinner
  // eslint-disable-next-line import/namespace
  utils.getWinner = (p1, p2) => p2
  const winner = thumbWar(`Ken Wheeler`, `Kent C. Dodds`)
  expect(winner).toBe(`Kent C. Dodds`)
  // eslint-disable-next-line import/namespace
  utils.getWinner = originalGetWinner
})
複製程式碼

看上面的程式碼,你可以注意到以下幾點:1、我們必須採用import * as的形式引入utils,以便於接下來可以操作這個物件(後面會談到,這種形式有啥壞處)。2、我們需要先把要mock的函式原始值儲存起來,然後在測試後恢復原來的值,這樣其他用到utils的測試才能不受這個測試用例的影響。

上面的所有操作都是為了我們能夠mock getWinner函式,而實際上的mock操作只有一行程式碼:

utils.getWinner = (p1, p2) => p2
複製程式碼

這就是所謂的猴子補丁,目前來看它是有效的(我們現在能夠確定猜拳遊戲中一個確定的勝者了),但是仍然有很多不足。首先,讓我們感到噁心的是這些eslint warning,所以我們加入了很多eslint-disable(再次強調,不要在你的程式碼中這麼搞,後面我們還會提到它)。第二,我們仍然不知道getWinner函式是否呼叫了我們期望它被呼叫的次數(2次,三局兩勝嘛)。對於我們的應用來說,這也許是不重要的,但對於本文要講的mock來說是很重要的。所以,接下來我們來優化它。

第2步

接下來我們增加一些程式碼,以確定getWinner函式被呼叫了兩次,並且確認每次呼叫的時候,都傳入了正確的引數。

import thumbWar from `../thumb-war`
import * as utils from `../utils`
test(`returns winner`, () => {
  const originalGetWinner = utils.getWinner
  // eslint-disable-next-line import/namespace
  utils.getWinner = (...args) => {
    utils.getWinner.mock.calls.push(args)
    return args[1]
  }
  utils.getWinner.mock = {calls: []}
  const winner = thumbWar(`Ken Wheeler`, `Kent C. Dodds`)
  expect(winner).toBe(`Kent C. Dodds`)
  expect(utils.getWinner.mock.calls).toHaveLength(2)
  utils.getWinner.mock.calls.forEach(args => {
    expect(args).toEqual([`Ken Wheeler`, `Kent C. Dodds`])
  })
  // eslint-disable-next-line import/namespace
  utils.getWinner = originalGetWinner
})
複製程式碼

上面的程式碼我們加入了一個mock物件,用以儲存被mock函式在被呼叫時產生的一些後設資料。有了它,我們可以給出下面兩個斷言:

expect(utils.getWinner.mock.calls).toHaveLength(2)
utils.getWinner.mock.calls.forEach(args => {
  expect(args).toEqual([`Ken Wheeler`, `Kent C. Dodds`])
})
複製程式碼

這兩個斷言確保我們的mock函式被適當地呼叫了(傳入了正確的引數),並且呼叫的次數也正確(對於三局兩勝來說就是2次)。

既然現在我們的mock可以提現真實執行的情景,我們可以對我們的程式碼(thumbWar)更有資訊了。但是不好的一點是,我們必須要給出這個mock函式到底在做啥。TODO

第3步

目前為止,一切都好,但噁心的是我們必須要手動加入追蹤邏輯以記錄mock函式的呼叫資訊。Jest內建了這種mock功能,接下來我們使用Jest簡化我們的程式碼:

import thumbWar from `../thumb-war`
import * as utils from `../utils`
test(`returns winner`, () => {
  const originalGetWinner = utils.getWinner
  // eslint-disable-next-line import/namespace
  utils.getWinner = jest.fn((p1, p2) => p2)
  const winner = thumbWar(`Ken Wheeler`, `Kent C. Dodds`)
  expect(winner).toBe(`Kent C. Dodds`)
  expect(utils.getWinner).toHaveBeenCalledTimes(2)
  utils.getWinner.mock.calls.forEach(args => {
    expect(args).toEqual([`Ken Wheeler`, `Kent C. Dodds`])
  })
  // eslint-disable-next-line import/namespace
  utils.getWinner = originalGetWinner
})
複製程式碼

這裡我們只是使用jest.fngetWinner的mock函式包起來了。基本功能跟我們之前自己實現的mock差不多,但是使用Jest的mock,我們可以使用一些Jest提供的指定斷言(比如toHaveBeenCalledTines),顯然更方便。不幸的是,Jest並沒有提供類似nthCalledWidth(好像快要支援了)這樣的API,否則我們就可以避免這些forEach語句了。但即使這樣,一切看起來尚好。

另外一件我不喜歡的事是要手動儲存originalGetWinner,然後在測試結束後恢復原狀。還要那些煩人的eslint註釋(這很重要,我們一會兒會專門說這個)。接下來,我們看一下我們能不能用Jest提供的工具把我們的程式碼進一步簡化。

第4步

幸運的是,Jest有一個工具函式叫spyOn,提供了我們所需的功能。

import thumbWar from `../thumb-war`
import * as utils from `../utils`
test(`returns winner`, () => {
  jest.spyOn(utils, `getWinner`)
  utils.getWinner.mockImplementation((p1, p2) => p2)
  const winner = thumbWar(`Ken Wheeler`, `Kent C. Dodds`)
  expect(winner).toBe(`Kent C. Dodds`)
  utils.getWinner.mockRestore()
})
複製程式碼

不錯,程式碼確實簡單了不少。Mock函式又被叫做spy(這也是為啥這個API叫spyOn)。預設Jest會儲存getWinner的原始實現,並且追蹤它是如何被呼叫的。我們不希望原始的實現被呼叫,所以我們用mockImplementation去指定我們呼叫它時應該返回什麼結果。最後,我們再用mockRestore去清除mock操作,以保留getWinner本來的與昂子。(跟我們之前所做的一樣,對吧)。

還記得之前我們提到的eslint error嗎,我們接下來解決這個問題。

第5步

我們遇到的ESLint報錯非常重要。我們之所以會遇到這個問題,是因為我們寫程式碼的方式導致eslint-plugin-import不能靜態檢測我們是否破壞了它的規則。這個規則非常重要,就是:import/namespace。之所以我們會破壞這個規則是因為對import名稱空間的成員進行了賦值

為啥這會是個問題呢?因為我們的ES6程式碼被Babel轉成了CommonJS的形式,而CommonJS中有所謂的require快取。當我import 一個模組時,我實際上是在import哪個模組中函式的執行環境。所以當我在不同的檔案引入相同的模組,並嘗試去修改這個執行環境,這個修改僅對當前檔案有效。所以如果你很依賴這個特性,你很可能在升級ES6模組時遇到坑。

Jest模擬了一套模組系統,從而可以非常容易的無縫將我們的mock實現替換掉原始實現,現在我們的測試變成了這個樣子:

import thumbWar from `../thumb-war`
import * as utilsMock from `../utils`
jest.mock(`../utils`, () => {
  return {
    getWinner: jest.fn((p1, p2) => p2),
  }
})
test(`returns winner`, () => {
  const winner = thumbWar(`Ken Wheeler`, `Kent C. Dodds`)
  expect(winner).toBe(`Kent C. Dodds`)
  expect(utilsMock.getWinner).toHaveBeenCalledTimes(2)
  utilsMock.getWinner.mock.calls.forEach(args => {
    expect(args).toEqual([`Ken Wheeler`, `Kent C. Dodds`])
  })
})
複製程式碼

我們直接告訴Jest我們希望所有的檔案去使用我們的mock版本。注意我修改了import過來的名字為utilsMock。這不是必須的,但是我喜歡用這種方式表明這裡import過來的是個mock版本而非原始實現。

常見問題:如果你想要僅mock某個模組中的一個函式,也許你想看看require.requireActualAPI

第6步

到這裡就幾乎快要說完了。假如我們要在多個測試中用到getWinner函式,但是又不想到處複製貼上這段mock程式碼怎麼辦?這就需要用到__mocks__資料夾提供方便了。所以我們在我們想要對其mock的檔案旁邊建立一個__mocks__資料夾,然後建立一個相同名字的檔案:

other/whats-a-mock/
├── __mocks__
│   └── utils.js
├── __tests__/
├── thumb-war.js
└── utils.js
複製程式碼

__mocks__/utils.js檔案中,我們這麼寫:

// __mocks__/utils.js
export const getWinner = jest.fn((p1, p2) => p2)
複製程式碼

這樣我們的測試可以寫成:

// __tests__/thumb-war.js
import thumbWar from `../thumb-war`
import * as utilsMock from `../utils`
jest.mock(`../utils`)
test(`returns winner`, () => {
  const winner = thumbWar(`Ken Wheeler`, `Kent C. Dodds`)
  expect(winner).toBe(`Kent C. Dodds`)
  expect(utilsMock.getWinner).toHaveBeenCalledTimes(2)
  utilsMock.getWinner.mock.calls.forEach(args => {
    expect(args).toEqual([`Ken Wheeler`, `Kent C. Dodds`])
  })
})
複製程式碼

現在我們只需要寫jest.mock(pathToModule)就可以了,它會自動使用我們剛才建立的mock實現。

我們也許不想mock實現總是返回第二個選手獲勝,這時我們就可以針對特定的測試用mockImplementation給出期望的實現,進而測試其他情況是否測試通過。你也可以在你的mock中使用一些工具庫方法,想怎麼玩兒都行。

End.

相關文章