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

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

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

# 为什么选择 jest

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

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

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

# 安装与配置

$ npm i --save-dev jest

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

"scripts": {
  "test": "jest"
}

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

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

# 小试牛刀

# 1. 创建项目目录

.
├── src
│   └── sum.js
├── test
│   └── sum.test.js
├── .gitignore
├── jest.config.js
├── README.md
└── package.json

# 2. 创建 src/sum.js

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

# 3. 创建 test/sum.test.js

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

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

# 执行测试命令

$ npm test
章节配图

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

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

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

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

# 断言

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

# 相等

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

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

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

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

添加 not 可以表达相反匹配

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

# 真值

  • toBeNull 只匹配 null
  • toBeUndefined 只匹配 undefined
  • toBeDefined 与 toBeUndefined 相反
  • toBeTruthy 匹配任何 if 语句为真
  • toBeFalsy 匹配任何 if 语句为假
test('null', () => {
  const n = null;
  expect(n).toBeNull();
  expect(n).toBeDefined();
  expect(n).not.toBeUndefined();
  expect(n).not.toBeTruthy();
  expect(n).toBeFalsy();
});
test('zero', () => {
  const z = 0;
  expect(z).not.toBeNull();
  expect(z).toBeDefined();
  expect(z).not.toBeUndefined();
  expect(z).not.toBeTruthy();
  expect(z).toBeFalsy();
});

# 数字

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

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

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

# 包含

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

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

# 测试异步函数

jest 对几种常见的异步方法提供了测试支持

src/async.js

module.exports = {
  cb: (fn) => {
    setTimeout(() => {
      fn('peanut butter');
    }, 300);
  },
  pm: () => {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve('peanut butter');
      }, 300);
    });
  },
  aa: async () => {
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve('peanut butter');
      }, 300);
    });
  },
};

test/async.test.js

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

# 回调

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

test('callback data is peanut butter', (done) => {
  function callback(data) {
    try {
      expect(data).toBe('peanut butter');
      done();
    } catch (error) {
      done(error);
    }
  }
  cb(callback);
});

# Promise

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

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

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

# async/await

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

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

# 任务钩子

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

# 多次重复

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

beforeEach(() => {
  initializeCityDatabase();
});
afterEach(() => {
  clearCityDatabase();
});
test('city database has Vienna', () => {
  expect(isCity('Vienna')).toBeTruthy();
});
test('city database has San Juan', () => {
  expect(isCity('San Juan')).toBeTruthy();
});

# 一次性设置

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

beforeAll(() => {
  return initializeCityDatabase();
});
afterAll(() => {
  return clearCityDatabase();
});
test('city database has Vienna', () => {
  expect(isCity('Vienna')).toBeTruthy();
});
test('city database has San Juan', () => {
  expect(isCity('San Juan')).toBeTruthy();
});

# 作用域

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

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

# mock

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

# mock 函数

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

const mockFn = jest.fn();
mockFn
  .mockReturnValueOnce(10)
  .mockReturnValueOnce('x')
  .mockReturnValue(true);
console.log(myMock(), myMock(), myMock(), myMock());
// > 10, 'x', true, true

# mock 模块

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

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

# babel & typeScript

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

# 安装依赖

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

# 添加 babel 配置

// babel.config.js
module.exports = {
  presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript'],
};

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

# react 测试

# 安装依赖

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

# 配置 babel

// babel.config.js
module.exports = {
  presets: [['@babel/preset-env', { targets: { node: 'current' } }], '@babel/preset-typescript', '@babel/preset-react'],
};

# 编写组件

// src/checkbox-with-label.js
import React, { useState } from 'react';
export default function CheckboxWithLabel(props) {
  const [checkStatus, setCheckStatus] = useState(false);
  const { labelOn, labelOff } = props;
  function onChange() {
    setCheckStatus(!checkStatus);
  }
  return (
    <label>
      <input type="checkbox" checked={checkStatus} onChange={onChange} />
      {checkStatus ? labelOn : labelOff}
    </label>
  );
}

# 编写测试用例

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

// test/checkbox-with-label.test.js
import React from 'react';
import Enzyme, { shallow } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import CheckboxWithLabel from '../src/checkbox-with-label';
beforeAll(() => {
  // enzyme 初始化
  Enzyme.configure({ adapter: new Adapter() });
});
test('CheckboxWithLabel changes the text after click', () => {
  // 渲染组件
  const checkbox = shallow(<CheckboxWithLabel labelOn="On" labelOff="Off" />);
  expect(checkbox.text()).toEqual('Off');
  // 触发事件
  checkbox.find('input').simulate('change');
  expect(checkbox.text()).toEqual('On');
});

# 测试覆盖率

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

"scripts": {
  "test": "jest",
  "coverage": "jest --coverage"
}

执行命令 npm run coverage 即可

章节配图

命令执行完成会在项目根目录添加 coverage 文件夹,使用浏览器打开 coverage/lcov-report/index.html 文件,有可视化的测试报告

章节配图

项目完整代码:https://github.com/Samaritan89/test-demo (opens new window)

使用 Jest 单元测试 (opens new window)