测试是软件开发工作的重要一环,甚至有一种测试驱动开发(Test-Driven Development)的研发模式,要求整个研发工作是从编写测试用例开始。测试根据不同的维度有多种分类方式,按照测试阶段主要有单元测试、集成测试和系统测试,而单元测试是保障程序基本正确性的重中之重。

单元测试(Unit Tesing)是针对程序的最小部件,检查代码是否会按照预期工作的一种测试手段。在过程式编程中最小就是一个函数,在面向对象编程中最小部件就是对象方法。

下文介绍使用 jest 对 Node.js 程序进行单元测试

为什么选择 jest

单元测试的执行通常需要测试规范、断言、mock、覆盖率工具等支持,上述工具在繁荣的 Node.js 生态中有很多优秀实现,但组合起来使用会带来两个问题

  1. 多种工具的选择和学习有一定的成本
  2. 把多个工具组合成特定测试解决方案的配置复杂

而 Jest 是用来创建、执行和构建测试用例的 JavaScript 测试库,自身包含了 驱动、断言库、mock 、代码覆盖率等多种功能,配置使用相当简单

安装与配置

  1. $ npm i --save-dev jest

把 jest 安装到项目的 devDepecencies 后,在 package.json 添加配置

  1. "scripts": {
  2. "test": "jest"
  3. }

这样就可以使用命令 npm test 执行测试代码了

根目录下的 jest.config.js 文件可以自定义 jest 的详细配置,虽然 jest 相关配置也可以在 package.json 内,但为了可读性推荐在独立文件配置

小试牛刀

1.创建项目目录

  1. .
  2. ├── src
  3. └── sum.js
  4. ├── test
  5. └── sum.test.js
  6. ├── .gitignore
  7. ├── jest.config.js
  8. ├── README.md
  9. └── package.json

2. 创建 src/sum.js

  1. function sum(a, b) {
  2. return a + b;
  3. }
  4. module.exports = sum;

3. 创建 test/sum.test.js

  1. const sum = require('../src/sum');
  2. test('1 + 2 = 3', () => {
  3. expect(sum(1, 2)).toBe(3);
  4. });

在测试用例中使用 expect(x).toBe(y) 的方式表达 x 与 y 相同,类似 Node.js 提供的 assert(x, y) 断言,相对而言 jest 提供的语法有更好的语义性和可读性

执行测试命令

  1. $ npm test

image.png
jest 会自动运行 sum.test.js 文件,其默认匹配规则

  1. 匹配 __test__ 文件夹下的 .js 文件(.jsx .ts .tsx 也可以)
  2. 匹配所有后缀为 .test.js.spec.js 的文件(.jsx .ts .tsx 也可以)

可以通过根目录下的 jest.config.js 文件自定义测试文件匹配规则

  1. module.exports = {
  2. testMatch: [ // glob 格式
  3. "**/__tests__/**/*.[jt]s?(x)",
  4. "**/?(*.)+(spec|test).[jt]s?(x)"
  5. ],
  6. // 正则表达式格式,与 testMatch 互斥,不能同时声明
  7. // testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.[jt]sx?$',
  8. };

断言

jest 提供了 BDD 风格的断言支持,功能十分丰富,介绍几个最常用的

相等

.toBe() 使用 Object.is 来测试两个值精准相等

  1. expect(2 + 2).toBe(4);

如果测试对象可以使用 toEqual() ,递归检查数组或对象的每个字段

  1. const data = {one: 1};
  2. data['two'] = 2;
  3. expect(data).toEqual({one: 1, two: 2});

添加 not 可以表达相反匹配

  1. expect(a + b).not.toBe(0);

真值

  • toBeNull 只匹配 null
  • toBeUndefined 只匹配 undefined
  • toBeDefinedtoBeUndefined 相反
  • toBeTruthy 匹配任何 if 语句为真
  • toBeFalsy 匹配任何 if 语句为假
  1. test('null', () => {
  2. const n = null;
  3. expect(n).toBeNull();
  4. expect(n).toBeDefined();
  5. expect(n).not.toBeUndefined();
  6. expect(n).not.toBeTruthy();
  7. expect(n).toBeFalsy();
  8. });
  9. test('zero', () => {
  10. const z = 0;
  11. expect(z).not.toBeNull();
  12. expect(z).toBeDefined();
  13. expect(z).not.toBeUndefined();
  14. expect(z).not.toBeTruthy();
  15. expect(z).toBeFalsy();
  16. });

数字

  1. test('two plus two', () => {
  2. const value = 2 + 2;
  3. expect(value).toBeGreaterThan(3);
  4. expect(value).toBeGreaterThanOrEqual(3.5);
  5. expect(value).toBeLessThan(5);
  6. expect(value).toBeLessThanOrEqual(4.5);
  7. });

对于比较浮点数相等,使用 toBeCloseTo 而不是 toEqual

  1. test('两个浮点数字相加', () => {
  2. const value = 0.1 + 0.2;
  3. expect(value).toBe(0.3); // 这句会报错,因为浮点数有舍入误差
  4. expect(value).toBeCloseTo(0.3); // 这句可以运行
  5. });

包含

可以通过 toContain来检查一个数组或可迭代对象是否包含某个特定项

  1. expect(shoppingList).toContain('beer');

测试异步函数

jest 对几种常见的异步方法提供了测试支持
src/async.js

  1. module.exports = {
  2. cb: fn => {
  3. setTimeout(() => {
  4. fn('peanut butter');
  5. }, 300);
  6. },
  7. pm: () => {
  8. return new Promise(resolve => {
  9. setTimeout(() => {
  10. resolve('peanut butter');
  11. }, 300);
  12. });
  13. },
  14. aa: async () => {
  15. return new Promise(resolve => {
  16. setTimeout(() => {
  17. resolve('peanut butter');
  18. }, 300);
  19. });
  20. }
  21. };

test/async.test.js

  1. const { cb, pm, aa } = require('../src/async');

回调

test 方法的第二个函数传入 done 可以用来标识回调执行完成

  1. test('callback data is peanut butter', done => {
  2. function callback(data) {
  3. try {
  4. expect(data).toBe('peanut butter');
  5. done();
  6. } catch (error) {
  7. done(error);
  8. }
  9. }
  10. cb(callback);
  11. });

Promise

  1. test('promise then data is peanut butter', () => {
  2. return pm().then(data => {
  3. expect(data).toBe('peanut butter');
  4. });
  5. });

一定要把 Promise 做为返回吃,否则测试用例会在异步方法执行完之前结束,如果希望单独测试 resolve 可以使用另外一种书写方式

  1. test('promise resolve data is peanut butter', () => {
  2. return expect(pm()).resolves.toBe('peanut butter');
  3. });

async/await

async/await 测试比较简单,只要外层方法声明为 async 即可

  1. test('async/await data is peanut butter', async () => {
  2. const data = await aa();
  3. expect(data).toBe('peanut butter');
  4. });

任务钩子

写测试用例的时候经常需要在运行测试前做一些预执行,和在运行测试后进行一些清理工作,Jest 提供辅助函数来处理这个问题

多次重复

如果在每个测试任务开始前需要执行数据初始化工作、结束后执行数据清理工作,可以使用 beforeEach 和 afterEach

  1. beforeEach(() => {
  2. initializeCityDatabase();
  3. });
  4. afterEach(() => {
  5. clearCityDatabase();
  6. });
  7. test('city database has Vienna', () => {
  8. expect(isCity('Vienna')).toBeTruthy();
  9. });
  10. test('city database has San Juan', () => {
  11. expect(isCity('San Juan')).toBeTruthy();
  12. });

一次性设置

如果相关任务全局只需要执行一次,可以使用 beforeAll 和 afterAll

  1. beforeAll(() => {
  2. return initializeCityDatabase();
  3. });
  4. afterAll(() => {
  5. return clearCityDatabase();
  6. });
  7. test('city database has Vienna', () => {
  8. expect(isCity('Vienna')).toBeTruthy();
  9. });
  10. test('city database has San Juan', () => {
  11. expect(isCity('San Juan')).toBeTruthy();
  12. });

作用域

默认情况下,before 和 after 的块可以应用到文件中的每个测试。 此外可以通过 describe 块来将测试分组。 当 before 和 after 的块在 describe 块内部时,则其只适用于该 describe 块内的测试

  1. describe('Scoped / Nested block', () => {
  2. beforeAll(() => console.log('2 - beforeAll'));
  3. afterAll(() => console.log('2 - afterAll'));
  4. beforeEach(() => console.log('2 - beforeEach'));
  5. afterEach(() => console.log('2 - afterEach'));
  6. test('', () => console.log('2 - test'));
  7. });

mock

在很多时候测试用例需要在相关环境下才能正常运行,jest 提供了丰富的环境模拟支持

mock 函数

使用 jest.fn() 就可以 mock 一个函数,mock 函数有 .mock 属性,标识函数被调用及返回值信息

  1. const mockFn = jest.fn();
  2. mockFn
  3. .mockReturnValueOnce(10)
  4. .mockReturnValueOnce('x')
  5. .mockReturnValue(true);
  6. console.log(myMock(), myMock(), myMock(), myMock());
  7. // > 10, 'x', true, true

mock 模块

使用 jest.mock(模块名) 可以 mock 一个模块,比如某些功能依赖了 axios 发异步请求,在实际测试的时候我们希望直接返回既定结果,不用发请求,就可以 mock axios

  1. // src/user.js
  2. const axios = require('axios');
  3. class Users {
  4. static all() {
  5. return axios.get('/users.json').then(resp => resp.data);
  6. }
  7. }
  8. module.exports = Users;
  1. // /src/user.test.js
  2. const axios = require('axios');
  3. const Users = require('../src/user');
  4. jest.mock('axios'); // mock axios
  5. test('should fetch users', () => {
  6. const users = [{ name: 'Bob' }];
  7. const resp = { data: users };
  8. // 修改其 axios.get 方法,直接返回结果,避免发请求
  9. axios.get.mockResolvedValue(resp);
  10. // 也可以模拟其实现
  11. // axios.get.mockImplementation(() => Promise.resolve(resp));
  12. return Users.all().then(data => expect(data).toEqual(users));
  13. });

babel & typeScript

现在很多前端代码直接使用了 ES6 和 Typescript,jest 可以通过简单配置支持

安装依赖

  1. $ npm i -D babel-jest @babel/core @babel/preset-env @babel/preset-typescript @types/jest

添加 babel 配置

  1. // babel.config.js
  2. module.exports = {
  3. presets: [
  4. ['@babel/preset-env', { targets: { node: 'current' } }],
  5. '@babel/preset-typescript',
  6. ],
  7. };

这样测试用例也可以用 ES6 + TypeScript 了

react 测试

安装依赖

  1. $ npm i -S react react-dom
  2. $ npm i -D @babel/preset-react enzyme enzyme-adapter-react-16

配置 babel

  1. // babel.config.js
  2. module.exports = {
  3. presets: [
  4. ['@babel/preset-env', { targets: { node: 'current' } }],
  5. '@babel/preset-typescript',
  6. '@babel/preset-react',
  7. ],
  8. };

编写组件

  1. // src/checkbox-with-label.js
  2. import React, { useState } from 'react';
  3. export default function CheckboxWithLabel(props) {
  4. const [checkStatus, setCheckStatus] = useState(false);
  5. const { labelOn, labelOff } = props;
  6. function onChange() {
  7. setCheckStatus(!checkStatus);
  8. }
  9. return (
  10. <label>
  11. <input
  12. type="checkbox"
  13. checked={checkStatus}
  14. onChange={onChange}
  15. />
  16. {checkStatus ? labelOn : labelOff}
  17. </label>
  18. );
  19. }

编写测试用例

react 测试有多种方式,在 demo 中使用最好理解的 enzyme

  1. // test/checkbox-with-label.test.js
  2. import React from 'react';
  3. import Enzyme, { shallow } from 'enzyme';
  4. import Adapter from 'enzyme-adapter-react-16';
  5. import CheckboxWithLabel from '../src/checkbox-with-label';
  6. beforeAll(() => {
  7. // enzyme 初始化
  8. Enzyme.configure({ adapter: new Adapter() });
  9. })
  10. test('CheckboxWithLabel changes the text after click', () => {
  11. // 渲染组件
  12. const checkbox = shallow(<CheckboxWithLabel labelOn="On" labelOff="Off" />);
  13. expect(checkbox.text()).toEqual('Off');
  14. // 触发事件
  15. checkbox.find('input').simulate('change');
  16. expect(checkbox.text()).toEqual('On');
  17. });

测试覆盖率

jest 还提供了测试覆盖率的支持,执行命令 npm test -- --coverage 或者配置 package.json

  1. "scripts": {
  2. "test": "jest",
  3. "coverage": "jest --coverage"
  4. }

执行命令 npm run coverage 即可
image.png
命令执行完成会在项目根目录添加 coverage 文件夹,使用浏览器打开 coverage/lcov-report/index.html 文件,有可视化的测试报告
image.png
项目完整代码:https://github.com/Samaritan89/test-demo