到底啥是JavaScript Mock

1,973 阅读8分钟

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