有人觉得,单元测试这玩意儿一直是个玄学,尤其是前端的单元测试。后端单元测试至少还能起到验证业务逻辑的作用,但是在传统的 Web 项目中,前端的主要作用只是简单的输出页面 UI,并附加一些命令式的交互绑定。总而言之,前端既难以测试(因为绝大多数命令式的代码和 DOM 强绑定,很难使用单元测试独立出来),又没有太大的必要去测试。 但是随着技术的发展,类似 React、Angular 这种大型的 UI 框架如雨后春笋般冒出来并且被越来越多的人开发者所接受。打开一个最近流行的前端项目,你会发现前端项目的复杂度已大大超过以前,前端也开始承担越来越多的业务逻辑。于此同时,由于大型 UI 框架的使用,前端代码也变得越来越声明式,意味着其可测性变得越来越好。重新审视我们的前端单元测试,我们会发现其重要程度已经不亚于后端的单元测试。 说到前端测试框架,市面上其实已经有了 Jasmine/Mocha 这种成熟的测试框架,但是今天我们更多的来聊聊 Jest。

什么是 Jest

Jest 是 Facebook 推出的一款开源的前端测试框架,或者我更愿意称其为包含了断言库 / 测试框架 / Mock 框架 / CLI 的开箱即用的前端测试套件。

Jest 和其他测试框架的区别

如果将常用的前端单元测试所需要的工具做横向分隔的话,其实可以分为这么几个大类。

测试框架 运行环境 Mock 框架 断言库 覆盖率工具
Jasmine karma sinon.js chai Istanbul
mocha should.js

我们常说的前端测试框架比如 Jasmine 或者 Mocha 其实某种程度上并不能独立的运行起来并且集成在我们日常的工作流之中。比如最常见的 Jasmine,Jasmine 基本上很难去脱离 Karma 去使用,并且 Jasmine 自带的断言和 Mock 有的时候也无法完全满足我们的需求,可能还需要搭配 Sinon.js 或者 chai,最后配合 Istanbul 进行覆盖率分析。在实际的工作中,当你想基于 Jasmine 去构建项目的测试框架时,实际上你会发现,你的绝大多数时间都花在了搭建这些工作链上。

磨刀不误砍柴工,但是磨刀的时间太久也会消耗技术人员的耐心和热情,让一件事情变得难以实施。

而 Jest 主打的就是其开箱即用的特性,只需要很少的配置就可以很方便的接入现有项目之中。如果你恰巧使用了creat-react-app这个脚手架的话,那么恭喜你,脚手架已经自带了 Jest 集成。

如何使用 Jest 来写单元测试

上面介绍了那么多,接下来让我们来 show 一 show 代码,看看到底怎么用 Jest 去写测试。

测试文件结构

假设我们有一个math.js文件,在文件中我们定义了两个函数。

1
2
3
4
5
// math.js

export const add = (a, b) => a + b;

export const multiple = (a, b) => a * b;

我们创建了这样一个 math.js 的测试文件。其中 describe 类似 Jasmine 中的 describe, 属于 test suite 的描述,而每个 test 或者 it 则描述了每个 test case。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// math.test.js
describe("math", () => {
  test("#should return result as a+b", () => {
    // test code
  });

  it("#should return result as a*b", () => {
    //test code
  });
});

关于 test suite 和 test case, 下图可以简单的描述其项目之间的关系。

当然 test suite 可以进行嵌套

1
2
3
4
5
6
7
describe("foo", () => {
  describe("bar", () => {
    it("foo bar", () => {
      //test code
    });
  });
});

test case 也可以脱离 test suite 独立运行

1
2
3
test("adds 1 + 2 to equal 3", () => {
  expect(sum(1, 2)).toBe(3);
});

断言

断言是一个测试用例中不可缺少的部分。

Jest 内置了很多断言,基本可以满足常用的需求。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
// 内置断言
test("two plus two is four", () => {
  expect(2 + 2).toBe(4);
});

test("object assignment", () => {
  const data = { one: 1 };
  data["two"] = 2;
  expect(data).toEqual({ one: 1, two: 2 });
});

test("adding positive numbers is not zero", () => {
  for (let a = 1; a < 10; a++) {
    for (let b = 1; b < 10; b++) {
      expect(a + b).not.toBe(0);
    }
  }
});

//

当你觉得 Jest 的内置断言不够灵活时,你也可以创建自己的断言。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// Custom断言
expect.extend({
  toBeDivisibleBy(received, argument) {
    const pass = received % argument == 0;
    if (pass) {
      return {
        message: () =>
          `expected ${received} not to be divisible by ${argument}`,
        pass: true
      };
    } else {
      return {
        message: () => `expected ${received} to be divisible by ${argument}`,
        pass: false
      };
    }
  }
});

test("even and odd numbers", () => {
  expect(100).toBeDivisibleBy(2);
  expect(101).not.toBeDivisibleBy(2);
  expect({ apples: 6, bananas: 3 }).toEqual({
    apples: expect.toBeDivisibleBy(2),
    bananas: expect.not.toBeDivisibleBy(2)
  });
});

Mock

Mock 是单元测试中经常使用的一种技术。单元测试,顾名思义测试的重点是某个具体单元。但是在实际代码中,代码与代码之间,模块与模块之间总是会存在着相互引用。这个时候,剥离出这种单元的依赖,让测试更加独立,使用到的技术就是 Mock。

比如在实际项目中,经常会有这种代码之间的引用存在。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// fooBar.js
export const getFooResult = () => {
  // foo logic here
};
export const getBarResult = () => {
  // bar logic here
};

// caculate.js
import { getFooResult, getBarResult } from "./math";

export const getFooBarResult = () => getFooResult() + getBarResult();

此时,getFooResult() 和 getBarResult() 就是 getFooBarResult 这个函数的依赖。如果我们关注的点是 getFooBarResult 这个函数,我们就应该把 getFooResult 和 getBarResult Mock 掉,剥离这种依赖。下面是一个使用 Jest 进行 Mock 的例子。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// calculate.test.js
import { getFooBarResult } from "./calculate";
import * as fooBar from './math';

test('getResult should return result getFooResult() + getBarResult()', () => {
  // mock add方法和multiple方法
  fooBar.getFooBarResult = jest.fn(() => 10);
  fooBar.getBarResult = jest.fn(() => 5);

  const result = getResult();

  expect(result).toEqual(15);
});

在上面的例子中,我们关注的逻辑是 getFooBarResult()这个函数中 getFooResult() + getBarResult()这段相加的逻辑,进行Mock之后我们能很好的进行独立的单元测试。

当然,Jest也支持Mock技术中的Spy。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// calculate.test.js
import { getFooBarResult } from "./calculate";
import * as fooBar from './math';

test('getResult should return result getFooResult() + getBarResult()', () => {
  // mock add方法和multiple方法
  fooBar.getFooBarResult = jest.fn(() => 10);
  fooBar.getBarResult = jest.fn(() => 5);

  const result = getResult();

  // 监控getFooResult和getBarResult的调用情况.
  expect(fooBar.getFooResult).toHaveBeenCalled(1);
  expect(fooBar.getBarResult).toHaveBeenCalled(1);
});

对于使用 export default foo 或者 module.exports语法的模块导出,我们可以使用mockImplementation进行mock。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// foo.js
module.exports = function() {
  // some implementation;
};

// test.js
jest.mock('../foo'); // this happens automatically with automocking
const foo = require('../foo');

// foo is a mock function
foo.mockImplementation(() => 42);
foo();
// > 42

Jest中关于Mock还有很多用法,感兴趣的可以仔细阅读一下官方文档。

jest-Mock-Functions

异步代码

在实际应用中, js有很大一部分的交互是异步的交互,因此,如何方便的测试异步代码也是很需要考量的一个地方。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// request.js
const http = require('http');

export default function request(url) {
  return new Promise(resolve => {
    // This is an example of an http request, for example to fetch
    // user data from an API.
    // This module is being mocked in __mocks__/request.js
    http.get({path: url}, response => {
      let data = '';
      response.on('data', _data => (data += _data));
      response.on('end', () => resolve(data));
    });
  });
}

在测试request这个函数时,我们实际上并不想去真正的发送网络请求,我们需要做的只是验证我们的get call发出去了,并且在我们做出了对于对应response正确的响应。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// __mocks__/request.js
const users = {
  4: {name: 'Mark'},
  5: {name: 'Paul'},
};

export default function request(url) {
  return new Promise((resolve, reject) => {
    const userID = parseInt(url.substr('/users/'.length), 10);
    process.nextTick(
      () =>
        users[userID]
          ? resolve(users[userID])
          : reject({
              error: 'User with ' + userID + ' not found.',
            }),
    );
  });
}

我们创建了这样一个mock文件,该文件指明了request的mock。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// __tests__/user-test.js
jest.mock('../request');

import * as user from '../user';

// The assertion for a promise must be returned.
it('works with promises', () => {
  expect.assertions(1);
  return user.getUserName(4).then(data => expect(data).toEqual('Mark'));
});

it('works with resolves', () => {
  expect.assertions(1);
  return expect(user.getUserName(5)).resolves.toEqual('Paul');
});

在测试文件中,我们手动调用jest.mock来告诉Jest我们不需要automock, 而是使用我们创建的mock文件。

关于更多异步代码的测试,请查阅官方文档

Async-code testing

总结

Jest是一套很强大的开箱即用的测试套件,其中的用法丰富多变,本文涉及的只是很基础很简单的一部分,如果有更多的需求,我也鼓励大家更多去阅读官方的文档。

参考

Jest 官方文档

https://juejin.im/post/597aa518f265da3e345f3262