天天看點

到底啥是JavaScript Mock

原文: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.fn

getWinner

的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.requireActual

API

第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.

原文釋出時間為:2018年06月24日

原文作者:妖僧風月

本文來源: 

掘金 https://juejin.im/entry/5b3a29f95188256228041f46

如需轉載請聯系原作者

繼續閱讀