使用 jest 单元测试

1,782 阅读2分钟

测试是软件开发工作的重要一环,甚至有一种测试驱动开发(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

image.png

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 来测试两个值精准相等

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

  • toBeDefinedtoBeUndefined 相反

  • 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 提供辅助函数来处理这个问题

多次重复

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

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 即可

image.png

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

image.png

项目完整代码:github.com/Samaritan89…