什么是JavaScript模拟

90 阅读9分钟

这是一个很好的后续,但实际上,什么是JavaScript测试?所以,我们开始吧!

为了学习mock,我们必须要有东西来测试,有东西来模拟,所以这里是我们今天要测试的模块:

// thumb-war.js
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

这是一个拇指战争的游戏,你要在三个人中玩出最好的两个。它使用了一个叫getWinner 的函数,这个函数来自utils。getWinner 返回获胜的玩家,如果是平局则返回空值。我们将假装这是对某个第三方机器学习服务的调用,该服务的测试环境我们无法控制,也不可靠,所以我们要模拟它进行测试。这是一种(罕见的)情况,在这种情况下,嘲弄是你可靠地测试你的代码的唯一选择。(我还是把它做成同步的,以进一步简化我们的例子)。

此外,除非我们在测试中重新实现getWinner 的所有内部工作,否则我们没有办法真正做出有用的断言,因为拇指战争的赢家是不确定的。因此,在没有嘲弄的情况下,我们的测试只能做到这一点。

// thumb-war.0.js
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)
})

我们只能断言赢家是其中一个玩家,也许这就足够了。但是如果我们真的想确保我们的thumbWar函数与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 = (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
})

你会注意到一些事情。首先,我们必须将utils模块作为一个* import,这样我们就有了一个可以操作的对象(注意:请谨慎阅读!后面会有更多关于为什么这样做是不好的)。然后我们需要在测试开始时存储原始函数,并在结束时恢复它,这样其他测试就不会受到我们对utils 模块所做修改的影响。

所有这些只是为我们的变化的实际嘲弄部分做准备。嘲弄是指这样的一句话:"你是谁?

utils.getWinner = (p1, p2) => p2

这是猴子打补丁的嘲弄。它是有效的(我们现在能够确保有一个特定的thumbWar 游戏的赢家),但这有一些限制。有一件事很烦人,那就是eslint警告,所以我们已经禁用了它(再次强调,不要真的这样做,因为它使你的代码不符合规范!)。同样,以后会有更多关于这个问题的内容)。另外,我们实际上并不确定utils.getWinner 函数是否被调用得太多了(两次,对于3局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 对象,这样我们就可以保留一些关于函数如何被调用的模拟元数据。这使得我们可以添加这两个断言。

expect(utils.getWinner.mock.calls).toHaveLength(2)
utils.getWinner.mock.calls.forEach(args => {
  expect(args).toEqual(['Ken Wheeler', 'Kent C. Dodds'])
})

这可以帮助我们确保我们的模拟函数被正确地调用(用正确的参数),并且它被调用的次数是正确的(三次中的两次)。

现在只要我们的模拟能够模拟出真实世界的版本所做的事情,我们就可以找回一点信心,尽管要模拟出getWinner ,但我们的代码还是在工作。实施一些契约测试,以确保getWinner 和第三方服务之间的契约得到控制,这也许不是一个坏主意。但我要把这个问题留给你的想象力!

所以,所有这些东西都很酷,但要一直跟踪我们的模拟被调用的时间,这很烦人。事实证明,我们所做的是手动实现一个模拟函数,而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
})

在这里,我们简单地把我们的getWinner mock实现用 jest.fn.这有效地做了所有我们正在做的事情,除了因为它是一个特殊的Jest模拟函数,有一些特殊的断言我们可以专门用于这个目的(如toHaveBeenCalledTimes )。Jest有一个断言叫做 toHaveBeenNthCalledWith的断言,所以我们可以避免使用我们的forEach ,但是我认为现在这样就可以了(幸运的是我们以Jest的方式实现了我们自己的元数据集合,所以我们不需要改变这个断言。真不错!)。

我不喜欢的下一件事是必须跟踪originalGetWinner ,并在最后恢复。我也被那些我必须放在那里的eslint注释所困扰(记住!这个规则是超级重要的,我们一会儿会讨论它)。让我们看看我们是否可以用另一个Jest工具来进一步简化事情。

幸运的是,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()
})

很好!我们真的简化了事情!模拟函数也被称为间谍(这就是为什么这个API被称为spyOn )。默认情况下,Jest会保留getWinner 的原始实现,但仍会跟踪它的调用方式。但对我们来说,我们不希望原始实现被调用,所以我们使用mockImplementation 来模拟它被调用时的情况。然后在最后,我们使用mockRestore 来清理我们自己,就像我们之前那样。很好吧!?

还记得我们看到的那些eslint错误吗?接下来让我们来解决这些问题

我们看到的ESLint错误实际上是非常重要的。我们绕过了这个问题,因为我们改变了我们的代码,以至于eslint-plugin-import ,无法静态地检测到我们实际上仍然在破坏这个规则。但是这个规则实际上是非常重要的。这条规则是。 import/namespace.在这种情况下,它被破坏的原因是。

对一个导入的命名空间的成员进行赋值的报告。

那么为什么会有这样的问题呢?这是因为我们的代码能够工作,只是因为Babel如何将它转译到CommonJS以及require缓存如何工作的运气。当我导入一个模块时,我正在导入该模块中的函数的不可变的绑定,所以如果我在两个不同的文件中导入同一个模块并试图突变绑定,突变将只适用于发生突变的模块(实际上我不确定这一点,我可能会得到一个错误,这可能会更好)。所以如果你依赖这个,当你试图升级到ES模块的时候,你可能会被吓哭

也就是说,我们要做的事情也并不符合规范(这是测试工具为我们做的一些魔术),但我们的代码看起来符合规范,这很重要,这样团队里的人就不会学到坏习惯,而这些坏习惯可能会进入应用代码。

因此,为了解决这个问题,我们可以尝试在require.cache,把模块的实际实现换成我们的模拟版本,但是我们会发现imports ,在我们的代码运行之前就已经发生了,所以如果不把它拉到另一个文件里,我们就不能及时运行它。另外,我的孩子们就要起床了,我必须完成这个任务!

所以现在我们来看看jest.mock API。因为Jest实际上为我们模拟了模块系统,它可以非常容易地、无缝地将一个模块的模拟实现换成真正的模块!我们的测试看起来是这样的。这就是我们的测试现在的样子:

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我们想让所有的文件都使用我们的模拟版本,然后噗!它就会这样做了。请注意,我把导入的名字从utils 改成了utilsMock 。这不是必须的,但我喜欢这样做来表达这样的意图:这应该是导入模块的模拟版本,而不是真实的东西。

常见的问题。如果你只想在一个模块中模拟几个函数中的一个,那么你可能会喜欢 jest.requireActualAPI。

好的,那么我们就快完成了。如果我们要在几个测试中使用这个getWinner 函数,而我们又不想到处复制/粘贴这个模拟函数,怎么办?这就是 __mocks__目录就派上用场了。所以我们在我们要模拟的文件旁边创建一个__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) ,它就会自动接收我们为我们创建的模拟文件。

现在我们可能不希望这个模拟文件总是返回第二个玩家,所以我们可以在特定的测试中使用mockImplementation ,以验证如果我们返回第二个,然后是第一个,然后又是第二个,等等。请自行尝试。如果你愿意,你也可以给你的模拟配备一些实用工具。世界是你的囊中之物。

祝您好运!