目前缺少单元测试在前端工程中十分常见,揣摩导致这种情况的原因主要有以下两个:
- 前端缺陷在功能测试中易于暴露(肉眼即可观察);
- 开发人员不了解单元测试方法,缺少实践经验,对单元测试能带来的好处存在怀疑。
缺少单元测试也带了一些常见的问题,比如:
- 在项目初期开发时,由于没有单元测试,单个组件无法即时调试,必须等待页面框架代码完成才能调试;
- 在调试过程中,对于一些极限值必须要求服务来 Mock 或者增加侵入式的调试代码,这也在一定程度上影响了开发效率;
- 在需要重构代码时,更加依赖功能测试,需要浪费较多资源部署测试环境,有时由于牵涉的业务功能点过多,甚至导致开发人员不敢对老代码进行重构。
完善的单元测试除了能解决以上问题,还会带来以下好处:
- 为了进行 Mock,通常会要求开发人员对代码进行重构解耦,这在一定程度使的代码结构更加趋于合理;
- 单元测试可以给出每项测试的响应时间,合理划分的单元测试有助于定位代码的性能问题;
- 单元测试还是一份很好的业务文档,每项测试的描述都可以体现业务逻辑。
综上,我们可以通过完善单元测试来提高代码质量,并在一定程度上提高开发效率。 进行单元测试通常需要使用单元测试框架,常见的 JS 单元测试框架有 mocha(https://mochajs.org/),jesmine(https://jasmine.github.io/),chai(https://www.chaijs.com/)等,本文主要通过官方文档实例讲述 Jest 测试框架的基本用法。
一、Jest 简介
Jest 是 Facebook 开源的一款 JS 单元测试框架,它也是 React 目前使用的单元测试框架。 目前除了 Facebook 外,Twitter、Nytimes、Airbnb 也在使用 Jest。Jest 除了基本的断言和 Mock 功能外,还有快照测试、实时监控模式、覆盖度报告等实用功能。 同时 Jest 几乎不需要做任何配置便可使用。
二、快速起步
通过 npm 添加依赖;
- npm install --save-dev jest
写一个 sum 函数,完成 a + b 的计算;
- // sum.js
- function sum(a, b) {
- return a + b;
- }
- module.exports = sum;
编写 sum 测试;
- // sum.test.js
- const sum = require('./sum');
- test('adds 1 + 2 to equal 3', () => {
- expect(sum(1, 2)).toBe(3);
- });
配置 package.json;
- {
- "scripts": {
- "test": "jest"
- }
- }
运行测试;
- npm test
使用 --watch 参数可以启动一个监控界面,当文件发生变化时,会便会运行相关的测试;
- npm test -- --watch
使用 --coverage 参数,测试结束时还会得到一份测试覆盖度报告。
- npm test -- --coverage
三、基本测试(匹配器)
Jest 基本测试通过 expect 实现,expect 函数返回一个期望值对象,该对象提供了大量工具方法用于做结果继定,使用十分方便。详细可参见 API 文档(https://jestjs.io/docs/en/expect),下面对需要注意的点做简单描述:
1. 相等判断:toBe 使用 Object.is 来判断相等,toEqual 会递归判断 Object 的每一个字段,对数值来说 toBe 和 toEqual 相同;
- 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});
- });
2. 判断符点数:可使用 toBeCloseTo 来解决 JS 浮点精度带来的问题,如下示例;
- test('adding floating point numbers', () => {
- const value = 0.1 + 0.2; // 0.30000000000000004
- expect(value).toBeCloseTo(0.3); // 测试通过
- });
3. 判断异常:使用 toThrow 可以测试某个函数在运行时是否会抛出异常。
- function compileAndroidCode() {
- throw new ConfigError('you are using the wrong JDK');
- }
- test('compiling android goes as expected', () => {
- expect(compileAndroidCode).toThrow();
- expect(compileAndroidCode).toThrow(ConfigError);
- // 可以匹配异常的消息内容,也可以用正则来匹配异常消息内容
- expect(compileAndroidCode).toThrow('you are using the wrong JDK');
- expect(compileAndroidCode).toThrow(/JDK/);
- });
四、异步测试
Jest 提供了三种方式来支持异步测试:回调函数,Promise 以及 async 函数,下面举例说明:
1. 回调函数:为 Jest 测试函数的添加一个参数 done,如果 done 在函数体内被调用,那么测试直到 done 被调用才会结束,否则测试函数会在执行结束时立即结束;
- // 无效测试示例
- test('the data is peanut butter', () => {
- function callback(data) {
- // 以下不会执行
- expect(data).toBe('peanut butter');
- }
- // 异步获取数据
- fetchData(callback);
- // 测试会立即结束。
- });
- // 有效测试示例
- test('the data is peanut butter', done => {
- function callback(data) {
- expect(data).toBe('peanut butter');
- // done被调用时,测试结束
- done();
- }
- fetchData(callback);
- });
2. Promise:测试函数可以返回一个 Promise,Jest 会等待其 resolve,如果 reject 测试会直接失败;
- test('the data is peanut butter', () => {
- return fetchData().then(data => {
- expect(data).toBe('peanut butter');
- });
- });
如果需要测试 reject,可以使用 catch 来捕获异常,还需要使用 expect.assertions 来验证断言被调用的次数,以此保证 resolve 的 promise 返回失败。
- test('the fetch fails with an error', () => {
- expect.assertions(1);
- return fetchData().catch(e => expect(e).toMatch('error'));
- });
expect 还提供了 resolves、rejects 匹配器,使用这两个匹配器时必须把断言 return 出来,否则测试会立即结束。
- test('the data is peanut butter', () => {
- expect.assertions(1);
- return expect(fetchData()).resolves.toBe('peanut butter');
- });
- test('the fetch fails with an error', () => {
- expect.assertions(1);
- return expect(fetchData()).rejects.toMatch('error');
- });
3. async 函数:使用 async 函数做为测试函数即可,非常简单。
- test('the data is peanut butter', async () => {
- expect.assertions(1);
- const data = await fetchData();
- expect(data).toBe('peanut butter');
- });
- test('the fetch fails with an error', async () => {
- expect.assertions(1);
- try {
- await fetchData();
- } catch (e) {
- expect(e).toMatch('error');
- }
- });
五、测试前后逻辑处理
有时我们需要在测试前完成一些准备工作,测试后做一些清理工作,Jest 为这种场景提供了工具函数。
- beforeAll:当前文件中所有测试执行前触发,只执行一次;
- beforeEach:当前文件中每个测试执行前都会触发;
- afterEach: 当前文件中每个测试结束后都会触发;
- afterAll: 当前文件中所有测试执行结束后触发,只执行一次。
以上函数都支持异步调用,同上一节中异步测试的用法一致,支持回调函数,Promise 及 async 函数。 上述函数可用在不同的 describe 作用域,以此来完成不同 describe 下的独立逻辑。
示例:
- beforeAll(() => console.log('1 - beforeAll'));
- afterAll(() => console.log('1 - afterAll'));
- beforeEach(() => console.log('1 - beforeEach'));
- afterEach(() => console.log('1 - afterEach'));
- test('', () => console.log('1 - test'));
- 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'));
- });
- // 1 - beforeAll
- // 1 - beforeEach
- // 1 - test
- // 1 - afterEach
- // 2 - beforeAll
- // 1 - beforeEach
- // 2 - beforeEach
- // 2 - test
- // 2 - afterEach
- // 1 - afterEach
- // 2 - afterAll
- // 1 - afterAll
六、Mock 函数
使用 jest.fn 可以得到一个 mock 函数。
该函数可以用来测试代码间的联系,通过其 .mock 字段可以取到被调用的次数、传入的参数、返回值以及作为构造函数时的实例化对象。
- const mockCallback = jest.fn(x => 42 + x);
- [0, 1].forEach(mockCallback);
- // Mock函数被调用两次
- expect(mockCallback.mock.calls.length).toBe(2);
- // 第一次调用Mock函数时,第一个参数为0
- expect(mockCallback.mock.calls[0][0]).toBe(0);
- // 第二次调用Mock函数时,第一个参数为1
- expect(mockCallback.mock.calls[1][0]).toBe(1);
- // 第一次调用Mock函数的返回值为42
- expect(mockCallback.mock.results[0].value).toBe(42);
- const myMock = jest.fn();
- const a = new myMock();
- const b = {};
- const bound = myMock.bind(b);
- bound();
- console.log(myMock.mock.instances);
- // > [ <a>, <b> ]
Mock 函数还可以通过工具函数模拟返回值。
- const myMock = jest.fn();
- console.log(myMock());
- // > undefined
- myMock
- .mockReturnValueOnce(10)
- .mockReturnValueOnce('x')
- .mockReturnValue(true);
- console.log(myMock(), myMock(), myMock(), myMock());
- // > 10, 'x', true, true
七、模拟模块
使用 jest.mock 可以模拟引入模块,例如需要模拟远端 API 返回值时,可以直接 mock 调用 API 工具类(axios)。 jest.mock 会自动根据被 mock 的模块组织 mock 对象。mock 对象将具有原模块的字段和方法,每个字段和方法都可以通过工具函数 mock 具体逻辑。
下例对 Users 类进行测试,使用 mockResolvedValue 来模拟 axios.get 的返回值:
- // users.js
- import axios from 'axios';
- class Users {
- static all() {
- return axios.get('/users.json').then(resp => resp.data);
- }
- }
- export default Users;
- // users.test.js
- import axios from 'axios';
- import Users from './users';
- jest.mock('axios');
- test('should fetch users', () => {
- const resp = {data: [{name: 'Bob'}]};
- axios.get.mockResolvedValue(resp);
- // 也可以使用下行代码来重写get方法的实现
- // axios.get.mockImplementation(() => Promise.resolve(resp))
- return Users.all().then(users => expect(users).toEqual(resp.data));
- });
当模块是一个函数时,mock 对象可以通过 mockImplemetation 来模拟函数实现:
- // foo.js
- module.exports = function() {
- // 省略实现代码
- };
- // test.js
- jest.mock('../foo');
- const foo = require('../foo');
- // foo是一个mock函数
- foo.mockImplementation(() => 42);
- foo();
- // > 42
Jest 还可以通过重写一个完整的模块文件来 mock 模块,这个文件需要放在被 mock 模块文件同级目录下的 mocks 文件夹下。 对于 nodemodules 下的模块,mocks 需要与 nodemodules 文件夹平级。如下示例,为 node 的 fs 模块和 models/user.js 分别添加 mock 文件。
- ├── config
- ├── __mocks__
- │ └── fs.js
- ├── models
- │ ├── __mocks__
- │ │ └── user.js
- │ └── user.js
- ├── node_modules
- └── views
添加了 mock 文件之后,还需要在测试中调用 jest.mock('moduleName')来告知 Jest 使用 mock 对象。 下面这个示例,通过重写 fs 模块来测试 summarizeFilesInDirectorySync 函数。
- // FileSummarizer.js
- 'use strict';
- const fs = require('fs');
- function summarizeFilesInDirectorySync(directory) {
- return fs.readdirSync(directory).map(fileName => ({
- directory,
- fileName,
- }));
- }
- exports.summarizeFilesInDirectorySync = summarizeFilesInDirectorySync;
- // __mocks__/fs.js
- 'use strict';
- const path = require('path');
- // 先生成一个jest自动mock对象
- const fs = jest.genMockFromModule('fs');
- // 增加一个自定义函数使我们可以在测试中设置mock的fs模块api的返回值
- let mockFiles = Object.create(null);
- function __setMockFiles(newMockFiles) {
- mockFiles = Object.create(null);
- for (const file in newMockFiles) {
- const dir = path.dirname(file);
- if (!mockFiles[dir]) {
- mockFiles[dir] = [];
- }
- mockFiles[dir].push(path.basename(file));
- }
- }
- // 重写readdirSync方法,其中的返回值通过__setMockFiles方法来设置
- function readdirSync(directoryPath) {
- return mockFiles[directoryPath] || [];
- }
- fs.__setMockFiles = __setMockFiles;
- fs.readdirSync = readdirSync;
- module.exports = fs;
- // __tests__/FileSummarizer-test.js
- 'use strict';
- jest.mock('fs');
- describe('listFilesInDirectorySync', () => {
- const MOCK_FILE_INFO = {
- '/path/to/file1.js': 'console.log("file1 contents");',
- '/path/to/file2.txt': 'file2 contents',
- };
- beforeEach(() => {
- // 为测试添加mock的文件列表
- require('fs').__setMockFiles(MOCK_FILE_INFO);
- });
- test('includes all files in the directory in the summary', () => {
- const FileSummarizer = require('../FileSummarizer');
- const fileSummary = FileSummarizer.summarizeFilesInDirectorySync(
- '/path/to',
- );
- expect(fileSummary.length).toBe(2);
- });
- });
八、快照测试
快照测试是 Jest 内置了一种很有用的测试方式。快照测试可以用于保证界面不出现异常变化。 快照测试的基本原理是,渲染页面然后截图,将得到到截图与样本图片进行对比,以此来检查渲染是否符合预期。 两张图片对比不一致时,也有可能是预期发生了变化,这时就需要更新样本图片。
实际测试中,并不是必须对比图片,样本也可以是一份状态描述的字符串,这时只要对比序列化的字符串便可以验证渲染逻辑。
下例使用快照测试来对比一个 Object:
- // plain-object.test.js
- test('object equals', () => {
- const user = {
- createdAt: new Date(),
- id: Math.floor(Math.random() * 20),
- name: 'LeBron James',
- };
- expect(user).toMatchSnapshot();
- });
- // __snapshots__/plain-object.test.js.snap
- // Jest Snapshot v1, https://goo.gl/fbAQLP
- exports[`object equals 1`] = `
- Object {
- "createdAt": 2018-09-28T03:51:32.541Z,
- "id": 15,
- "name": "LeBron James",
- }
- `;
以上测试会失败,因为 createAt 和 ID 都会和快照中的字段比对不一致;如果期望的 createAt 和 ID 都是不确定的值,我们可以给 toMatchSnapShot 增加参数以匹配任意日期或数字, 这样测试便可以通过了。实际使用时,并不需要手工编写快照样本,Jest 可以在首次执行时可以自动生成快照样本,当测试代码有变化时,还可以通过 jest -u 来更新样本。 生成的样本会放在测试文件同级目录下的 snapshots 文件夹中。
- // plain-object.test.js
- test('object equals', () => {
- const user = {
- createdAt: new Date(),
- id: Math.floor(Math.random() * 20),
- name: 'LeBron James',
- };
- expect(user).toMatchSnapshot({
- createdAt: expect.any(Date),
- id: expect.any(Number),
- });
- });
- // Jest Snapshot v1, https://goo.gl/fbAQLP
- exports[`object equals 1`] = `
- Object {
- "createdAt": Any<Date>,
- "id": Any<Number>,
- "name": "LeBron James",
- }
- `;
虽然样本文件可以生成并且更新十分简单,但是我们不应该随意的修改样本文件。 当对比出错时,首先要考虑是不是被测试代码问题, 只有确定是期望发生变化时才去更新样本,需要把样本文件与代码同等重视起来。做代码审查时,也需要审查样本文件的变化。
快速测试非常适合用来测试 React 组件的渲染逻辑。 React 提供了测试套件用于创建 React 渲染树,由于将 React 渲染树最终转化为 dom 结构或 native 组件的操作是由 ReactDom/ReactNative 完成的,可以认为这一步是可靠的。 因此只需要对比 React 渲染树的快照样本便可以对渲染逻辑进行验证。示例如下:
(https://github.com/facebook/jest/blob/master/examples/snapshot/__tests__/link.react.test.js)
- import React from 'react';
- import Link from '../Link.react';
- import renderer from 'react-test-renderer';
- it('renders correctly', () => {
- const tree = renderer
- .create(<Link page="http://www.facebook.com">Facebook</Link>)
- .toJSON();
- expect(tree).toMatchSnapshot();
- });
- exports[`renders correctly 1`] = `
- <a
- className="normal"
- href="http://www.facebook.com"
- onMouseEnter={[Function]}
- onMouseLeave={[Function]}
- >
- </a>
- `;
使用快照测试验证 Redux 逻辑更加方便,reducer、actionCreator 返回值都是一个单纯的键值对对象,直接生成快照样本然后对比验证十分方便。如下示例:
- // reducer.js
- export const initialState = {
- error: null,
- isLoading: false,
- data: null
- };
- export default function loadData(state = initialState, action) {
- switch (action.type) {
- case 'failure':
- return {...state, error: action.error, isLoading: false};
- case 'request':
- return {...state, isLoading: true};
- case 'success':
- return {...state, isLoading: false, data: action.data};
- default:
- return state;
- }
- }
- // reducer.test.js
- import reducer from './reducer';
- test('load data success', () => {
- const action = { type: 'success', data: [1, 2, 3]};
- const initState = reducer(undefined, {});
- expect(reducer(initState, action)).toMatchSnapshot();
- });
- // Jest Snapshot v1, https://goo.gl/fbAQLP
- exports[`load data success 1`] = `
- Object {
- "data": Array [
- 1,
- 2,
- 3,
- ],
- "error": null,
- "isLoading": false,
- }
- `;
九、总结
以上内容主要是对 Jest 的主要功能介绍,更详细的文档可以参考 Jest 官网(https://jestjs.io/)。 Jest 是一款优秀的单元测试工具,其内置的快照测试更加简化了一些常见场景测试工作。 Jest 对 React 的支持十分完善,React 本身就是使用 Jest 做单元测试的,仅凭这一点便可使其成为基于 React 前端工程的首选单测框架。 最后祝愿大家在以后的工作中简单快速的完成单元测试,完成高质量工程代码。
本文来源于:Jest 前端单元测试框架-变化吧门户
特别声明:以上文章内容仅代表作者本人观点,不代表变化吧门户观点或立场。如有关于作品内容、版权或其它问题请于作品发表后的30日内与变化吧联系。
- 赞助本站
- 微信扫一扫
-
- 加入Q群
- QQ扫一扫
-
评论