Mock 大全

之前一直没有跟大家讲 Jest 的 Mock,就是想让大家先体会体会 Mock 的便利性以及复杂性。如果没有真正用过就看总结, 你会觉得:就这?

在前面一些章节,我们都遇到了不少 Mock 的场景,比如 window.location.href、Http 请求、函数的 Mock 等等。 相信对 Jest 的 Mock 都有大致印象了,所以这一章就来总结一下 Jest Mock 的一些实用场景吧。

一次性 Mock

这里的 “一次性” 是指在一个文件只 Mock 一次。Jest 的官方文档 在 Mock Functions 这一章 写了一些这种 Mock 的用法, 这里简单说一下。

Mock 模块

类似 axios 这样的第三方 NPM 库,可以这样实现 Mock:

  1. import axios from 'axios';
  2. import Users from './users';
  3. jest.mock('axios');
  4. test('should fetch users', () => {
  5. const users = [{name: 'Bob'}];
  6. const resp = {data: users};
  7. axios.get.mockResolvedValue(resp);
  8. // 你也可以使用下面这样的方式:
  9. // axios.get.mockImplementation(() => Promise.resolve(resp))
  10. return Users.all().then(data => expect(data).toEqual(users));
  11. });

但是这样的方法 并 不 好 用! 一般我们都会在项目里用 TypeScript,而 axios.get 是没有 jest 这些类型的,所以会报以下错误:

  1. TS2339: Property 'mockResolveValues' does not exist on type ' >(url: string, config?: AxiosRequestConfig | undefined) => Promise '.

正确的用法应该是用 jest.spyOn 来代替上面这种写法:

  1. import axios from 'axios';
  2. import Users from './users';
  3. jest.mock('axios');
  4. test('should fetch users', () => {
  5. const users = [{name: 'Bob'}];
  6. const resp = {data: users};
  7. jest.spyOn(axios, 'get').mockResolvedValue(resp);
  8. // 你也可以使用下面这样的方式:
  9. // jest.spyOn(axios, 'get').mockImplementation(() => Promise.resolve(resp))
  10. return Users.all().then(data => expect(data).toEqual(users));
  11. });

如果你非要用 axios.get.mockImplementation,那么建议你使用 ts-jest 里的 helper 函数 mocked

  1. import { mocked } from 'ts-jest/utils'
  2. import axios from 'axios';
  3. import Users from './users';
  4. jest.mock('axios');
  5. test('should fetch users', () => {
  6. const users = [{name: 'Bob'}];
  7. const resp = {data: users};
  8. const mockedGet = mocked(axios.get); // 带上 jest 的类型提示
  9. mockedGet.mockResolvedValue(resp); // 含有 jest 的类型提示
  10. return Users.all().then(data => expect(data).toEqual(users));
  11. });

::: danger ts-jest@28.0 已经把 mocked 移除了!这个函数被放到 jest-mock@27.4.0 这个包里了(内置到 Jest)! :::

部分依赖

上面会把整个模块的实现都给干掉,如果只想 Mock 部分内容,官方也提供了对应的写法:

  1. // foo-bar-baz.js
  2. export const foo = 'foo';
  3. export const bar = () => 'bar';
  4. export default () => 'baz';
  1. //test.js
  2. import defaultExport, {bar, foo} from '../foo-bar-baz';
  3. jest.mock('../foo-bar-baz', () => {
  4. // 真实的 foo-bar-baz 模块内容
  5. const originalModule = jest.requireActual('../foo-bar-baz');
  6. // Mock 默认导出和 foo 的内容
  7. return {
  8. __esModule: true,
  9. ...originalModule,
  10. default: jest.fn(() => 'mocked baz'),
  11. foo: 'mocked foo',
  12. };
  13. });
  14. test('should do a partial mock', () => {
  15. const defaultExportResult = defaultExport();
  16. expect(defaultExportResult).toBe('mocked baz');
  17. expect(defaultExport).toHaveBeenCalled();
  18. expect(foo).toBe('mocked foo');
  19. expect(bar()).toBe('bar');
  20. });

要注意的是:jest.mockjest.unmock 是一对非常特殊的 API,它们会被提升到所有 import 前。也就是说,上面这段代码看起是先 import 再 mock,而真实情况是,先 mock 了,再 import:

  1. // jest.mock 会被提升到所有 import 前
  2. jest.mock('../foo-bar-baz', () => {
  3. // 真实的 foo-bar-baz 模块内容
  4. const originalModule = jest.requireActual('../foo-bar-baz');
  5. // Mock 默认导出和 foo 的内容
  6. return {
  7. __esModule: true,
  8. ...originalModule,
  9. default: jest.fn(() => 'mocked baz'),
  10. foo: 'mocked foo',
  11. };
  12. });
  13. import defaultExport, {bar, foo} from '../foo-bar-baz';
  14. test('should do a partial mock', () => {
  15. const defaultExportResult = defaultExport();
  16. expect(defaultExportResult).toBe('mocked baz');
  17. expect(defaultExport).toHaveBeenCalled();
  18. expect(foo).toBe('mocked foo');
  19. expect(bar()).toBe('bar');
  20. });

只有这样你从 '../foor-bar-baz' 拿到的内容才是 Mock 内容。所以,也推荐大家在用 jest.mockjest.unmock 这两个 API 时最好写成先 mock 后 import 来避免理解上的歧义。

有同学会问:除了这俩还有没有别的 API 会这样提升的呢?我搜了很多地方,大家只需要记住这两就好了。

::: tip 这样的提升代码形为原本是通过 babel-plugin-jest-hoist 这个插件实现的,所以你在选 Jest 的转译器时,也要留意一下这些小坑。不过目前大部分的转译工具都有这个功能了。 :::

多次 Mock

官网对 Mock 的展示到上面就结束了。然而,我们经常会在同一个测试文件中给对象、函数、变量进行多次 Mock,以此模拟多种用例场景。

举个例子,我们添加一个配置文件 src/utils/env.ts

  1. // src/utils/env.ts
  2. export const config = {
  3. getEnv() {
  4. // 很复杂的逻辑...
  5. return 'test'
  6. }
  7. }

假如我们想测试一下不同环境下的一些行为:

  1. describe('环境', () => {
  2. it('开发环境', () => {
  3. // Mock config.getEnv => 'dev'
  4. // ...
  5. })
  6. it('正式环境', () => {
  7. // Mock config.getEnv => 'prod'
  8. // ...
  9. })
  10. })

如果还用 jest.mock 的方法来做 Mock 的话,就有点不合适了。下面来聊聊这种需要多次 Mock 的解决方法。

doMock

刚刚说到 jest.mock 会提升到整个文件最前面,这也导致我们无法再次修改 Mock 的实现。jest 还提供了另一个 API jest.doMock,它也会执行 Mock 操作,但是不会被提升。利用这个特性再加上内联 require 就可以实现多次 Mock 的效果了:

  1. // tests/utils/env/doMock.test.ts
  2. describe("doMock config", () => {
  3. beforeEach(() => {
  4. // 必须重置模块,否则无法再次应用 doMock 的内容
  5. jest.resetModules();
  6. })
  7. it('开发环境', () => {
  8. jest.doMock('utils/env', () => ({
  9. __esModule: true,
  10. config: {
  11. getEnv: () => 'dev'
  12. }
  13. }));
  14. const { config } = require('utils/env');
  15. expect(config.getEnv()).toEqual('dev');
  16. })
  17. it('正式环境', () => {
  18. jest.doMock('utils/env', () => ({
  19. __esModule: true,
  20. config: {
  21. getEnv: () => 'prod'
  22. }
  23. }));
  24. const { config } = require('utils/env');
  25. expect(config.getEnv()).toEqual('prod');
  26. })
  27. });

::: warning 需要注意的是:这里一共引用了两次 utils/env,因此要用 jest.resetModules 来重置前一次引入的模块内容。 :::

不过,这个方法也太挫了。

spyOn

对上面这种要多次 Mock 一个函数的情况,比较推荐的方法是用 jest.spyOn,添加 tests/utils/env/spyOn.test.ts

  1. // tests/utils/env/spyOn.test.ts
  2. import { config } from "utils/env";
  3. describe("spyOn config", () => {
  4. it('开发环境', () => {
  5. jest.spyOn(config, 'getEnv').mockReturnValue('dev')
  6. expect(config.getEnv()).toEqual('dev');
  7. })
  8. it('正式环境', () => {
  9. jest.spyOn(config, 'getEnv').mockReturnValue('prod')
  10. expect(config.getEnv()).toEqual('prod');
  11. })
  12. });

有人看到 jest.spyOn 会说:这个我早就知道了。别急,我们慢慢增加难度。

对象属性

假如我们的 env.tsconfig 里存不是 getEnv 函数,而是一个 env 属性:

  1. export const configObj = {
  2. env: 'test'
  3. }

改成了属性后,我们就要 Mock config.env 的属性值。而由于属性值不是函数,所以我们无法使用 jest.spyOn 来 Mock 了。

不过,如果你学过 阮一峰的《属性描述对象 - 7.存取器》 ,那么你应该还记得这一章讲到:对象属性可以定义自己的 gettersetter

  1. const obj = Object.defineProperty({}, 'p', {
  2. get: function () {
  3. return 'getter';
  4. },
  5. set: function (value) {
  6. console.log('setter: ' + value);
  7. }
  8. });
  9. obj.p // "getter"
  10. obj.p = 123 // "setter: 123"

我们把 env.ts 的代码改成以下面 getter 取值方式:

  1. export const configObj = {
  2. get env() {
  3. return 'test';
  4. }
  5. }

这里我们可以给 config.env 定义 envgetter,当获取 config.env 时,实际上相当于调用 config.envgetter 函数。 既然 getter 是个函数,我们又可以使用上面的 jest.spyOn 了:

  1. // tests/utils/env/getter.test.ts
  2. import { configObj } from "utils/env";
  3. describe("configObj env getter", () => {
  4. it('开发环境', () => {
  5. jest.spyOn(configObj, 'env', 'get').mockReturnValue('dev');
  6. expect(configObj.env).toEqual('dev');
  7. })
  8. it('正式环境', () => {
  9. jest.spyOn(configObj, 'env', 'get').mockReturnValue('prod');
  10. expect(configObj.env).toEqual('prod');
  11. })
  12. });

jest.spyOn 最后一个参数是对象属性的 accessTypeget),通过指定这个参数,我们就能控制对象的属性值了。

单独导出函数

上面的例子都是直接导出一个对象,算是比较理想的情况了。那如果 env.ts 导出的是一个函数呢:

  1. // src/utils/env.ts
  2. export const getEnv = () => 'test'

这下好了,我们连能够 spyOn 的对象都没了,这又该如何测呢?很简单,在导入的时候把它弄成对象就好了:

  1. // tests/utils/env/getEnv.test.ts
  2. import * as envUtils from 'utils/env';
  3. describe("getEnv", () => {
  4. it('开发环境', () => {
  5. jest.spyOn(envUtils, 'getEnv').mockReturnValue('dev')
  6. expect(envUtils.getEnv()).toEqual('dev')
  7. })
  8. it('正式环境', () => {
  9. jest.spyOn(envUtils, 'getEnv').mockReturnValue('prod')
  10. expect(envUtils.getEnv()).toEqual('prod')
  11. })
  12. });

有没有感觉开始魔幻起来了?

直接导出变量

再极端点,这次不直接导出 getEnv 函数,而是导出 env 变量:

  1. // src/utils/env.ts
  2. export const env = 'test';

聪明的同学会想到把 import * as envUtilsspyOn env getter 的方法:

  1. // tests/utils/env/env.test.ts
  2. import * as envUtils from 'utils/env';
  3. describe("env", () => {
  4. it('开发环境', () => {
  5. // @ts-ignore
  6. jest.spyOn(envUtils, 'env', 'get').mockReturnValue('dev')
  7. expect(envUtils.env).toEqual('dev');
  8. })
  9. it('正式环境', () => {
  10. // @ts-ignore
  11. jest.spyOn(envUtils, 'env', 'get').mockReturnValue('prod')
  12. expect(envUtils.env).toEqual('prod');
  13. })
  14. });

这里 TS 已经提示我们不能把 devprod 赋值给 test 了,没关系,我们先用 @ts-ignore 忽略它。硬着头皮换来的结果就是报错:

Mock 大全 - 图1

这里又为什么不能监听属性值的 getter 呢?因为这里的 envUtils 没有定义 getter accessor,所以这里无法使用 jest.spyOn 了。

::: tip 这里我也搜了一下,发现 这个 Issue: spyOn getter only works for static getters and not for instance gettersjest 只能监听对象静态属性的 getter 而不能监听对象实例的属性,大家也要注意一下这个点。 :::

要解决这个问题,我们可以通过强行赋值来解决它:

  1. import * as envUtils from 'utils/env';
  2. const originEnv = envUtils.env;
  3. describe("env", () => {
  4. afterEach(() => {
  5. // @ts-ignore
  6. envUtils.env = originEnv;
  7. })
  8. it('开发环境', () => {
  9. // @ts-ignore
  10. envUtils.env = 'dev'
  11. expect(envUtils.env).toEqual('dev');
  12. })
  13. it('正式环境', () => {
  14. // @ts-ignore
  15. envUtils.env = 'prod'
  16. expect(envUtils.env).toEqual('prod');
  17. })
  18. });

这里依然要用 @ts-ignore 来解决 “不能把 devprod 赋值给 test” 的报错,而且还要把 export const env = 'test' 改成 export let env = 'test' 才能进行赋值。多少有点挫了。

要解决上面这些问题,我们请出 Jest Mock 里最万能的方法 —— Object.defineProperty

  1. import * as envUtils from 'utils/env';
  2. const originEnv = envUtils.env;
  3. describe("env", () => {
  4. afterEach(() => {
  5. Object.defineProperty(envUtils, 'env', {
  6. value: originEnv,
  7. writable: true,
  8. })
  9. })
  10. it('开发环境', () => {
  11. Object.defineProperty(envUtils, 'env', {
  12. value: 'dev',
  13. writable: true,
  14. })
  15. expect(envUtils.env).toEqual('dev');
  16. })
  17. it('正式环境', () => {
  18. Object.defineProperty(envUtils, 'env', {
  19. value: 'prod',
  20. writable: true,
  21. })
  22. expect(envUtils.env).toEqual('prod');
  23. })
  24. });

这样我们既不需要用 @ts-ignore 也不需要把 const 改成 let 了。

::: tip 要注意的是,无论用直接赋值还是 Object.defineProperty,都需要在最开始记录 env 的值,然后加一个 afterEach 在执行每个用例后又赋值回去,否则会造成用例之间的污染! :::

上面就是在同一个文件里对不同测试用例多次 Mock 的一些技巧,基本能覆盖到 80% 的测试场景。

Object.defineProperty

相信看到上面最后一个例子的同学可能会震惊:Object.defineProperty 我一年都没用几次,这样做是不是不太正规呀?错了,这个 API 在前端测试的 Mock 里非常常见, 也是最万能的 Mock 方法。

比如,我们这里的 env 取的是 window.env 的全局变量时,你也可以用它来 Mock:

  1. Object.defineProperty(window, 'env', {
  2. value: 'dev'
  3. })

虽然这个 API 很强大,但是使用时会污染到别的测试用例,因此你需要在每个用例执行完后重新赋一次原来的值。而当你用它来 Mock 公共内容时, 比如 String.split,Array.map,你会污染所有测试文件!因此,不得万不得已,尽量不用它,应该看看有没有更好的 Mock 方法,或者换种测试策略。

奇行种

一路看起来,你会发现 Jest 的 Mock 方式非常 Hacky,然而我在搜索 Jest 的 Mock 技巧时,还发现了一些更 Hacky 的 奇行种 ,下面分享两个:

依赖注入

我们经常会遇到这样的情况:同一个文件中,A 函数里调用了 B 函数。

  1. export function getPlanet () {
  2. return 'world';
  3. }
  4. export default function getGreeting () {
  5. return `hello ${getPlanet()}!`;
  6. }

这里我们希望通过 Mock getPlanet 的不同值来检查 getGreeting 的返回值。解决这个问题的关键思路是引入默认变量:

  1. export function getPlanet () {
  2. return 'world';
  3. }
  4. export default function getGreeting (_getPlanet = getPlanet) {
  5. return `hello ${_getPlanet()}!`;
  6. }

然后在写测试时,用一个外部的 getPlanet 来替代同文件里的 getPlanet

  1. import getGreeting from '../greeting.dependency-injection';
  2. describe('getGreeting', () => {
  3. it('默认值', () => {
  4. expect(getGreeting()).toBe('hello world!');
  5. });
  6. it('输出 mars', () => {
  7. expect(getGreeting(() => 'mars')).toBe('hello mars!');
  8. });
  9. it('输出 jupiter', () => {
  10. expect(getGreeting(() => 'jupiter')).toBe('hello jupiter!');
  11. });
  12. it('回到默认值', () => {
  13. expect(getGreeting()).toBe('hello world!');
  14. });
  15. });

这就可以在不修改 getGreeting 的用法前提下实现 Mock,但是万一以后要添加参数了,这个方法还不是特别保险。

改写文件内容

这个就更奇葩了,直接改写文件内容。一般在测 fs 模块相关代码时,比如清理文件内容,追加文件内容等,我们会希望某个目录下已经有对应的内容了,而不是自己创建文件。 这时你可以使用 fs 提供的 __setVolumeContents 来实现:

  1. import cleanDirectory from '../clean-directory';
  2. import fs, { __setVolumeContents} from 'fs';
  3. jest.mock('fs'); // check out ../__mocks__/fs.js to see why this works!
  4. test('cleanDirectory() wipes away contents of /foo/bar/baz with 2 files', () => {
  5. __setVolumeContents({
  6. '/foo/bar/baz/qux1.txt': 'hello',
  7. '/foo/bar/baz/qux2.txt': 'world',
  8. });
  9. const numFilesDeleted = cleanDirectory();
  10. expect(numFilesDeleted).toBe(2);
  11. expect(fs.readdirSync('/foo/bar/baz')).toHaveLength(0);
  12. });
  13. test('cleanDirectory() wipes away contents of /foo/bar/baz with 3 files', () => {
  14. __setVolumeContents({
  15. '/foo/bar/baz/one.txt': '1',
  16. '/foo/bar/baz/two.txt': '2',
  17. '/foo/bar/baz/three.txt': '3',
  18. });
  19. const numFilesDeleted = cleanDirectory();
  20. expect(numFilesDeleted).toBe(3);
  21. expect(fs.readdirSync('/foo/bar/baz')).toHaveLength(0);
  22. });

不过我没有使用过这个方法,个人觉得还是写一个脚本放在 utils 里去自动创建需要的测试文件比较好,而不是用这种 Hacky 的方式。

总结

这一章里,我们学会了一些 Mock 技巧:

  • jest.mock 会提升到整个文件的顶端,先 mock 再 import
  • 可以用 ts-jest 提供的 mocked 函数让被 Mock 的函数自动拥有 jest 类型提示,不过这个已在 ts-jest@28.0 中被移除,放到了 jest 自带的 jest-mock 库中
  • doMock + 内联导入模块确实能解决修改 Mock 值的问题,但是太挫了,不推荐使用
  • 可以用 spyOn 来监听函数以及对象属性的 getter 来修改返回值
  • 可以用 Object.defineProperty 来更改变量值以及对象属性值,不过,这个方法也会带来一些副作用,需要手动重置修改过的值