用Jest mock隔离单元测试中的不可控因素

2,164 阅读6分钟

上回,我在《Jest测试CSS module组件报错怎么办?》一文中提到用Jest mock让Jest跳过对CSS及其他文件的识别转换。这项能力也可以应用于隔离测试中遇到的一些不可控或是不便测试的因素。如:

  • 第三方API调用
  • 像写文件这类,对测试没有帮助的操作
  • 应用环境提供的全局接口
  • 延时操作(用了计时器)

这些不可控因素会导致我们无法辨别到底是目标测试代码出错,还是由这些因素非预期的变化导致。那我们辛苦写的测试就失去了意义。

此时,我们可以用Jest内置的一系列工具来隔离这些不可控因素。让我们来看看不同的对象如何mock吧!

mock函数方法

让我们先从jest.fn()方法开始说起,它大概是最直观的一个mock方法了。

假设源代码和测试代码在同一个模块文件当中(当然一般不会放一起), 像这样:

function fun() { return true; }

test("mock fun", () => {
  expect(fun()).toBeTruthy();
});

如果在运行测试的时候想将fun()函数隔离,就可以利用JS中运行环境里遇到多个同名变量,则取用最靠近当前代码块的局部变量值的特点,将fun()替换为jest.fn():

function fun() { return true; }

test("mock fun", () => {
  const fun = jest.fn();
  expect(fun()).toBeTruthy();  // 测试无法通过
});

替换为jest.fn()之后我们就发现原先的测试无法通过了,因为我们用了默认的空函数mock,它并不会提供我们期望的输出。

如果需要特定的输出,那就给jest.fn()传一个工厂函数,告诉它应该返回什么值:

const fun = jest.fn(() => true);

然后上面的测试就可以通过啦!

mock ES6类

了解了jest.fn()之后,我们可以用它来mock其他的东西,比如ES6类。ES6类实际上是个语法糖,背后仍旧是Function原型链那一套。所以,我们同样可以用jest.fn()来mock:

const MockClass = jest.fn(() => {
  return {
    run: () => true,
  };
});

test("mock class", () => {
  const ins = new MockClass();
  expect(ins.run()).toBeTruthy();
});

以上我们传入了一个简单的工厂函数,返回了一个有测试要用的方法。当然这个方法也可以用jest.fn()来创建,测试它的调用情况。

mock异步请求

同样的,异步请求无非是调用了JS提供的接口方法。像fetch就可以返回一个Promise,只是稍微麻烦一些,因为我们通常有用到返回的Response对象转换数据,再做进一步处理。所以它的工厂函数会比较长:

global.fetch = jest.fn(() =>
  Promise.resolve({
    json: () =>
      Promise.resolve({
        data: 1,
      }),
  })
);

test("mock fetch", async () => {
  expect(await fetch("/").then((res) => res.json())).toMatchInlineSnapshot(`
    Object {
      "data": 1,
    }
  `);
});

如果这个json()方法没有mock,那可是会报错的。

像这样的有些复杂度高了,不如为这样的请求做一层封装,再直接mock封装了的函数就好:

function request() {
  return fetch("/").then((res) => res.json())
}

test("mock fetch", async () => {
  const requqest = jest.fn(() => Promise.resolve(true))
  expect(await request()).toBeTruthy();
});

当然也可以用别人mock好的,如jest-fetch-mock

还有个有趣的选项——mock服务器,如msw。既然同样都能隔离API请求,指定返回的数据,那当然用mock服务器更靠谱了,不必mock代码,只管mock数据,毕竟写代码就可能出错。

mock模块文件

mock一个模块或文件时,除了Jest测试CSS module组件报错怎么办?》一文中提到的用moduleNameMapper这样粗暴的方式之外,还可以用jest.mock()方法在测试代码中启用mock。

用NodeJS的fs模块举例:

jest.mock("fs");

const fs = require("fs");
const { readFileSync } = require("fs");

test("auto mock module 1", () => {
  fs.readFileSync("a");
  expect(fs.readFileSync.mock.calls[0][1]).toBe("a");
});

test("auto mock module 2", () => {
  readFileSync("b");
  expect(readFileSync.mock.calls[1][1]).toBe("b");
});

这是怎么做到的呢?NodeJS会将所有require的模块加载到缓存中,如果发现该模块已经存在,则会直接读取缓存。jest.mock正是利用了这一点,先行将mock加载进缓存,冒充指定模块,那之后我们require这个模块的时候就读到的是mock了。

而用ES6语法时,因为在文件根级使用的非动态加载的import关键字处理会被优先处理,因此mock语句放在import前面和后面没有什么区别:

import * as fs from "fs";
import { readFileSync } from "fs";

jest.mock("fs");

这又与我们之前讲的相违背了。其实,Jest考虑到了这一点,它在运行ES模块时,会优先处理jest.mock()而不是import。具体说明可见这里

清楚以上这些之后,我们就可以为更复杂的情况mock做隔离了。假设我现在有:

测试对象fun.js

import * as fs from "fs";
export default function fun() {
  fs.writeFileSync("a.txt", "a");
  return true;
}

以及测试代码文件fun.test.js

import fun from "./fun";

test("test fun()", () => {
  expect(fun()).toBeTruthy();
});

这时我想避免这个fs.writeFileSync()的写操作真实发生,那我就可以用jest.mock来帮忙:

// fun.test.js
import * as fs from "fs";
import fun from "./fun";

jest.mock("fs");

test("test fun()", () => {
  expect(fun()).toBeTruthy();
  expect(fs.writeFileSync).toBeCalledWith("a.txt", "a")
});

这里我们没有提供任何的工厂函数,是jest.mock()自动为所有模块export的成员创建了个默认的mock对象,返回值为undefined。如若需输出其他值,可以像jest.fn()一样为它传入一个工厂函数:

jest.mock("fs", () => ({
  writeFileSync: () => true
}));

或是在这个模块文件目录中创建一个__mocks__文件夹,并在这个文件夹内创建一个同名文件(如__mocks__/fun.js)。在这个同名文件中,就可以写我们的mock:

// __mocks__/fun.js
const fun = jest.fn(() => true);
export default fun;

然后在测试代码中调用jest.mock('./fun')时,Jest就运行这段代码来代替自己的默认mock行为。

如果想mock一个第三方模块(node_module),或是像fs这样的官方模块,则可以将__mocks__文件夹放在node_modules文件夹所在的目录层级,然后创建一个以模块名命名的文件。像这样:

├── __mocks__
│   └── fs.js
├── node_modules
├── src
│   ├── fun.js
│   └── fun.test.js

mock 延时操作

目前为止,看起来jest.fn()很万能,不过到时间控制方面就没那么好使了。因此,Jest内置了两个方法来帮助测试:

  • jest.useFakeTimers()将JS内置的setTimeout之类接口在测试环境中替换为jest.fn() mock
  • jest.advanceTimersByTime()提前执行n毫秒内的操作

然后就可以这样测试:

function fun(run) {
  setTimeout(() => {
    run();
  }, 1000);
}

jest.useFakeTimers();

test("mock timer", async () => {
  const run = jest.fn();
  fun(run);
  expect(setTimeout).toBeCalled();   // 测试setTimeout有被调用
  jest.advanceTimersByTime(1000);
  expect(run).toBeCalled();					// 测试我的延时回调有被调用
});

看看其他测试系列文:

加我微信