到底啥是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.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.



原文发布时间为:2018年06月24日
原文作者:妖僧风月
本文来源:  掘金  如需转载请联系原作者


相关文章
|
6月前
|
设计模式 JavaScript 前端开发
|
11天前
|
JavaScript 前端开发 算法
< JavaScript小技巧:如何优雅的用【一行代码 】实现Js中的常用功能 >
在开发中,采用简洁的语法和结构,遵循一致的命名规范,具有良好的代码组织和注释,能很好的提高代码的质量。可读性:易于阅读和理解。清晰的命名、简洁的语法和良好的代码结构可以使代码的意图更加明确,降低理解代码的难度,提高代码的可读性。可维护性:易于维护。当代码逻辑清晰、结构简洁时,开发者可以更快速地定位和修复bug,进行功能扩展或修改。同时,可读性高的代码也有助于后续的代码重构和优化。可扩展性:更具有扩展性和灵活性。清晰的代码结构和简洁的代码风格使得添加新功能、修改现有功能或扩展代码更加容易。
< JavaScript小技巧:如何优雅的用【一行代码 】实现Js中的常用功能 >
|
5月前
|
Web App开发 JSON JavaScript
【JS代码调试技巧】你必须知道的Javascript技巧汇总
【JS代码调试技巧】你必须知道的Javascript技巧汇总
32 0
|
JavaScript 前端开发
【前端】JS(javascript)中this的几种用法实例详解
this 是 JavaScript 语言的一个关键字,this 的指向其实是非常灵活的,它会根据调用function的对象不同,导致了 this 的指向不同。当在全局作用域下调用函数时,this 指向window。总的来说,this 就是函数运行时所在的环境对象。
98 1
|
Web App开发 JavaScript 前端开发
|
JavaScript 前端开发
js:JavaScript 核心语法快速入门
js:JavaScript 核心语法快速入门
151 0
|
JavaScript 前端开发 Java
【前端灵魂脚本语言JavaScript④】——JS中函数的使用
JS中也可以定义一些函数,java中的方法签名包含访问修饰符,返回值类型,方法名,参数列表,异常列表,但是JS中定义函数的语法相对简单很多,主要以function作为函数关键字,具备函数名和参数列表,但是没有访问修饰符也没有返回值类型关键字和异常列表。
【前端灵魂脚本语言JavaScript④】——JS中函数的使用
|
JavaScript 前端开发 Java
【前端灵魂脚本语言JavaScript①】——JS引入方式
 Javascript是一种由Netscape(网景)的LiveScript发展而来的原型化继承的面向对象的动态类型的区分大小写的客户端脚本语言,主要目的是为了解决服务器端语言,比如Perl,遗留的速度问题,为客户提供更流畅的浏览效果。
179 0
【前端灵魂脚本语言JavaScript①】——JS引入方式
|
JavaScript 前端开发 Java
【前端灵魂脚本语言JavaScript③】——JS中的流程控制
顺序结构表示程序中的各操作是按照它们出现的先后顺序执行的。
【前端灵魂脚本语言JavaScript③】——JS中的流程控制