微信号:QunarTL

介绍:Qunar技术沙龙是去哪儿网工程师小伙伴以及业界小伙伴们的学习交流平台.我们会分享Qunar和业界最前沿的热门技术趋势和话题;为中高端技术同学提供一个自由的技术交流和学习分享平台.

Jest 前端单元测试框架

2018-10-17 08:00 刘宏阳

刘宏阳


个人介绍:刘宏阳,2014 年加入去哪儿网技术团队。目前在大住宿事业部/技术部/大前端,参与了 ebooking、国际业务及酒店运营相关系统开发。个人对跨平台前端开发,前端新技术落地及系统工程化等有浓厚兴趣。


目前缺少单元测试在前端工程中十分常见,揣摩导致这种情况的原因主要有以下两个:

  1. 前端缺陷在功能测试中易于暴露(肉眼即可观察);

  2. 开发人员不了解单元测试方法,缺少实践经验,对单元测试能带来的好处存在怀疑。

缺少单元测试也带了一些常见的问题,比如:

  1. 在项目初期开发时,由于没有单元测试,单个组件无法即时调试,必须等待页面框架代码完成才能调试;

  2. 在调试过程中,对于一些极限值必须要求服务来 Mock 或者增加侵入式的调试代码,这也在一定程度上影响了开发效率;

  3. 在需要重构代码时,更加依赖功能测试,需要浪费较多资源部署测试环境,有时由于牵涉的业务功能点过多,甚至导致开发人员不敢对老代码进行重构。

完善的单元测试除了能解决以上问题,还会带来以下好处:

  1. 为了进行 Mock,通常会要求开发人员对代码进行重构解耦,这在一定程度使的代码结构更加趋于合理;

  2. 单元测试可以给出每项测试的响应时间,合理划分的单元测试有助于定位代码的性能问题;

  3. 单元测试还是一份很好的业务文档,每项测试的描述都可以体现业务逻辑。

综上,我们可以通过完善单元测试来提高代码质量,并在一定程度上提高开发效率。 进行单元测试通常需要使用单元测试框架,常见的 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 添加依赖;

 
           
  1. npm install --save-dev jest

写一个 sum 函数,完成 a + b 的计算;

 
           
  1. // sum.js

  2. function sum(a, b) {

  3.  return a + b;

  4. }

  5. module.exports = sum;

编写 sum 测试;

 
           
  1. // sum.test.js

  2. const sum = require('./sum');

  3. test('adds 1 + 2 to equal 3', () => {

  4.  expect(sum(1, 2)).toBe(3);

  5. });

配置 package.json;

 
           
  1. {

  2.  "scripts": {

  3.    "test": "jest"

  4.  }

  5. }

运行测试;

 
           
  1. npm test

使用 --watch 参数可以启动一个监控界面,当文件发生变化时,会便会运行相关的测试;

 
           
  1. npm test -- --watch

使用 --coverage 参数,测试结束时还会得到一份测试覆盖度报告。

 
           
  1. npm test -- --coverage

三、基本测试(匹配器)

Jest 基本测试通过 expect 实现,expect 函数返回一个期望值对象,该对象提供了大量工具方法用于做结果继定,使用十分方便。详细可参见 API 文档(https://jestjs.io/docs/en/expect),下面对需要注意的点做简单描述:

1. 相等判断:toBe 使用 Object.is 来判断相等,toEqual 会递归判断 Object 的每一个字段,对数值来说 toBe 和 toEqual 相同;

 
           
  1. test('two plus two is four', () => {

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

  3. });

  4. test('object assignment', () => {

  5.  const data = {one: 1};

  6.  data['two'] = 2;

  7.  expect(data).toEqual({one: 1, two: 2});

  8. });

2. 判断符点数:可使用 toBeCloseTo 来解决 JS 浮点精度带来的问题,如下示例;

 
           
  1. test('adding floating point numbers', () => {

  2.  const value = 0.1 + 0.2; // 0.30000000000000004

  3.  expect(value).toBeCloseTo(0.3);  // 测试通过

  4. });

3. 判断异常:使用 toThrow 可以测试某个函数在运行时是否会抛出异常。

 
           
  1. function compileAndroidCode() {

  2.  throw new ConfigError('you are using the wrong JDK');

  3. }

  4. test('compiling android goes as expected', () => {

  5.  expect(compileAndroidCode).toThrow();

  6.  expect(compileAndroidCode).toThrow(ConfigError);

  7.  // 可以匹配异常的消息内容,也可以用正则来匹配异常消息内容

  8.  expect(compileAndroidCode).toThrow('you are using the wrong JDK');

  9.  expect(compileAndroidCode).toThrow(/JDK/);

  10. });

四、异步测试

Jest 提供了三种方式来支持异步测试:回调函数,Promise 以及 async 函数,下面举例说明:

1. 回调函数:为 Jest 测试函数的添加一个参数 done,如果 done 在函数体内被调用,那么测试直到 done 被调用才会结束,否则测试函数会在执行结束时立即结束;

 
           
  1. // 无效测试示例

  2. test('the data is peanut butter', () => {

  3.  function callback(data) {

  4.    // 以下不会执行

  5.    expect(data).toBe('peanut butter');

  6.  }

  7.  // 异步获取数据

  8.  fetchData(callback);

  9.  // 测试会立即结束。

  10. });


 
           
  1. // 有效测试示例

  2. test('the data is peanut butter', done => {

  3.  function callback(data) {

  4.    expect(data).toBe('peanut butter');

  5.    // done被调用时,测试结束

  6.    done();

  7.  }

  8.  fetchData(callback);

  9. });

2. Promise:测试函数可以返回一个 Promise,Jest 会等待其 resolve,如果 reject 测试会直接失败;

 
           
  1. test('the data is peanut butter', () => {

  2.  return fetchData().then(data => {

  3.    expect(data).toBe('peanut butter');

  4.  });

  5. });

如果需要测试 reject,可以使用 catch 来捕获异常,还需要使用 expect.assertions 来验证断言被调用的次数,以此保证 resolve 的 promise 返回失败。

 
           
  1. test('the fetch fails with an error', () => {

  2.  expect.assertions(1);

  3.  return fetchData().catch(e => expect(e).toMatch('error'));

  4. });

expect 还提供了 resolves、rejects 匹配器,使用这两个匹配器时必须把断言 return 出来,否则测试会立即结束。

 
           
  1. test('the data is peanut butter', () => {

  2.  expect.assertions(1);

  3.  return expect(fetchData()).resolves.toBe('peanut butter');

  4. });

  5. test('the fetch fails with an error', () => {

  6.  expect.assertions(1);

  7.  return expect(fetchData()).rejects.toMatch('error');

  8. });

3. async 函数:使用 async 函数做为测试函数即可,非常简单。

 
           
  1. test('the data is peanut butter', async () => {

  2.  expect.assertions(1);

  3.  const data = await fetchData();

  4.  expect(data).toBe('peanut butter');

  5. });

  6. test('the fetch fails with an error', async () => {

  7.  expect.assertions(1);

  8.  try {

  9.    await fetchData();

  10.  } catch (e) {

  11.    expect(e).toMatch('error');

  12.  }

  13. });

五、测试前后逻辑处理

有时我们需要在测试前完成一些准备工作,测试后做一些清理工作,Jest 为这种场景提供了工具函数。

  1. beforeAll:当前文件中所有测试执行前触发,只执行一次;

  2. beforeEach:当前文件中每个测试执行前都会触发;

  3. afterEach: 当前文件中每个测试结束后都会触发;

  4. afterAll: 当前文件中所有测试执行结束后触发,只执行一次。

以上函数都支持异步调用,同上一节中异步测试的用法一致,支持回调函数,Promise 及 async 函数。 上述函数可用在不同的 describe 作用域,以此来完成不同 describe 下的独立逻辑。

示例:

 
           
  1. beforeAll(() => console.log('1 - beforeAll'));

  2. afterAll(() => console.log('1 - afterAll'));

  3. beforeEach(() => console.log('1 - beforeEach'));

  4. afterEach(() => console.log('1 - afterEach'));

  5. test('', () => console.log('1 - test'));

  6. describe('Scoped / Nested block', () => {

  7.  beforeAll(() => console.log('2 - beforeAll'));

  8.  afterAll(() => console.log('2 - afterAll'));

  9.  beforeEach(() => console.log('2 - beforeEach'));

  10.  afterEach(() => console.log('2 - afterEach'));

  11.  test('', () => console.log('2 - test'));

  12. });

  13. // 1 - beforeAll

  14. // 1 - beforeEach

  15. // 1 - test

  16. // 1 - afterEach

  17. // 2 - beforeAll

  18. // 1 - beforeEach

  19. // 2 - beforeEach

  20. // 2 - test

  21. // 2 - afterEach

  22. // 1 - afterEach

  23. // 2 - afterAll

  24. // 1 - afterAll

六、Mock 函数

使用 jest.fn 可以得到一个 mock 函数。

该函数可以用来测试代码间的联系,通过其 .mock 字段可以取到被调用的次数、传入的参数、返回值以及作为构造函数时的实例化对象。

 
           
  1. const mockCallback = jest.fn(x => 42 + x);

  2. [0, 1].forEach(mockCallback);

  3. // Mock函数被调用两次

  4. expect(mockCallback.mock.calls.length).toBe(2);

  5. // 第一次调用Mock函数时,第一个参数为0

  6. expect(mockCallback.mock.calls[0][0]).toBe(0);

  7. // 第二次调用Mock函数时,第一个参数为1

  8. expect(mockCallback.mock.calls[1][0]).toBe(1);

  9. // 第一次调用Mock函数的返回值为42

  10. expect(mockCallback.mock.results[0].value).toBe(42);


 
           
  1. const myMock = jest.fn();

  2. const a = new myMock();

  3. const b = {};

  4. const bound = myMock.bind(b);

  5. bound();

  6. console.log(myMock.mock.instances);

  7. // > [ <a>, <b> ]

Mock 函数还可以通过工具函数模拟返回值。

 
           
  1. const myMock = jest.fn();

  2. console.log(myMock());

  3. // > undefined

  4. myMock

  5.  .mockReturnValueOnce(10)

  6.  .mockReturnValueOnce('x')

  7.  .mockReturnValue(true);

  8. console.log(myMock(), myMock(), myMock(), myMock());

  9. // > 10, 'x', true, true

七、模拟模块

使用 jest.mock 可以模拟引入模块,例如需要模拟远端 API 返回值时,可以直接 mock 调用 API 工具类(axios)。 jest.mock 会自动根据被 mock 的模块组织 mock 对象。mock 对象将具有原模块的字段和方法,每个字段和方法都可以通过工具函数 mock 具体逻辑。

下例对 Users 类进行测试,使用 mockResolvedValue 来模拟 axios.get 的返回值:

 
           
  1. // users.js

  2. import axios from 'axios';

  3. class Users {

  4.  static all() {

  5.    return axios.get('/users.json').then(resp => resp.data);

  6.  }

  7. }

  8. export default Users;


 
           
  1. // users.test.js


  2. import axios from 'axios';

  3. import Users from './users';

  4. jest.mock('axios');

  5. test('should fetch users', () => {

  6.  const resp = {data: [{name: 'Bob'}]};

  7.  axios.get.mockResolvedValue(resp);

  8.  // 也可以使用下行代码来重写get方法的实现

  9.  // axios.get.mockImplementation(() => Promise.resolve(resp))

  10.  return Users.all().then(users => expect(users).toEqual(resp.data));

  11. });

当模块是一个函数时,mock 对象可以通过 mockImplemetation 来模拟函数实现:

 
           
  1. // foo.js

  2. module.exports = function() {

  3.  // 省略实现代码

  4. };


 
           
  1. // test.js

  2. jest.mock('../foo');

  3. const foo = require('../foo');

  4. // foo是一个mock函数

  5. foo.mockImplementation(() => 42);

  6. foo();

  7. // > 42

Jest 还可以通过重写一个完整的模块文件来 mock 模块,这个文件需要放在被 mock 模块文件同级目录下的 mocks 文件夹下。 对于 nodemodules 下的模块,mocks 需要与 nodemodules 文件夹平级。如下示例,为 node 的 fs 模块和 models/user.js 分别添加 mock 文件。

 
           
  1. ├── config

  2. ├── __mocks__

  3.   └── fs.js

  4. ├── models

  5.   ├── __mocks__

  6.     └── user.js

  7.   └── user.js

  8. ├── node_modules

  9. └── views

添加了 mock 文件之后,还需要在测试中调用 jest.mock('moduleName')来告知 Jest 使用 mock 对象。 下面这个示例,通过重写 fs 模块来测试 summarizeFilesInDirectorySync 函数。

 
           
  1. // FileSummarizer.js

  2. 'use strict';

  3. const fs = require('fs');

  4. function summarizeFilesInDirectorySync(directory) {

  5.  return fs.readdirSync(directory).map(fileName => ({

  6.    directory,

  7.    fileName,

  8.  }));

  9. }

  10. exports.summarizeFilesInDirectorySync = summarizeFilesInDirectorySync;


 
           
  1. // __mocks__/fs.js

  2. 'use strict';

  3. const path = require('path');

  4. // 先生成一个jest自动mock对象

  5. const fs = jest.genMockFromModule('fs');

  6. // 增加一个自定义函数使我们可以在测试中设置mock的fs模块api的返回值

  7. let mockFiles = Object.create(null);

  8. function __setMockFiles(newMockFiles) {

  9.  mockFiles = Object.create(null);

  10.  for (const file in newMockFiles) {

  11.    const dir = path.dirname(file);

  12.    if (!mockFiles[dir]) {

  13.      mockFiles[dir] = [];

  14.    }

  15.    mockFiles[dir].push(path.basename(file));

  16.  }

  17. }

  18. // 重写readdirSync方法,其中的返回值通过__setMockFiles方法来设置

  19. function readdirSync(directoryPath) {

  20.  return mockFiles[directoryPath] || [];

  21. }

  22. fs.__setMockFiles = __setMockFiles;

  23. fs.readdirSync = readdirSync;

  24. module.exports = fs;


 
           
  1. // __tests__/FileSummarizer-test.js

  2. 'use strict';

  3. jest.mock('fs');

  4. describe('listFilesInDirectorySync', () => {

  5.  const MOCK_FILE_INFO = {

  6.    '/path/to/file1.js': 'console.log("file1 contents");',

  7.    '/path/to/file2.txt': 'file2 contents',

  8.  };

  9.  beforeEach(() => {

  10.    // 为测试添加mock的文件列表

  11.    require('fs').__setMockFiles(MOCK_FILE_INFO);

  12.  });

  13.  test('includes all files in the directory in the summary', () => {

  14.    const FileSummarizer = require('../FileSummarizer');

  15.    const fileSummary = FileSummarizer.summarizeFilesInDirectorySync(

  16.      '/path/to',

  17.    );

  18.    expect(fileSummary.length).toBe(2);

  19.  });

  20. });

八、快照测试

快照测试是 Jest 内置了一种很有用的测试方式。快照测试可以用于保证界面不出现异常变化。 快照测试的基本原理是,渲染页面然后截图,将得到到截图与样本图片进行对比,以此来检查渲染是否符合预期。 两张图片对比不一致时,也有可能是预期发生了变化,这时就需要更新样本图片。

实际测试中,并不是必须对比图片,样本也可以是一份状态描述的字符串,这时只要对比序列化的字符串便可以验证渲染逻辑。

下例使用快照测试来对比一个 Object:

 
           
  1. // plain-object.test.js

  2. test('object equals', () => {

  3.  const user = {

  4.    createdAt: new Date(),

  5.    id: Math.floor(Math.random() * 20),

  6.    name: 'LeBron James',

  7.  };

  8.  expect(user).toMatchSnapshot();

  9. });


 
           
  1. // __snapshots__/plain-object.test.js.snap

  2. // Jest Snapshot v1, https://goo.gl/fbAQLP

  3. exports[`object equals 1`] = `

  4. Object {

  5.  "createdAt": 2018-09-28T03:51:32.541Z,

  6.  "id": 15,

  7.  "name": "LeBron James",

  8. }

  9. `;

以上测试会失败,因为 createAt 和 ID 都会和快照中的字段比对不一致;如果期望的 createAt 和 ID 都是不确定的值,我们可以给 toMatchSnapShot 增加参数以匹配任意日期或数字, 这样测试便可以通过了。实际使用时,并不需要手工编写快照样本,Jest 可以在首次执行时可以自动生成快照样本,当测试代码有变化时,还可以通过 jest -u 来更新样本。 生成的样本会放在测试文件同级目录下的 snapshots 文件夹中。

 
           
  1. // plain-object.test.js

  2. test('object equals', () => {

  3.  const user = {

  4.    createdAt: new Date(),

  5.    id: Math.floor(Math.random() * 20),

  6.    name: 'LeBron James',

  7.  };

  8.  expect(user).toMatchSnapshot({

  9.    createdAt: expect.any(Date),

  10.    id: expect.any(Number),

  11.  });

  12. });


 
           
  1. // Jest Snapshot v1, https://goo.gl/fbAQLP

  2. exports[`object equals 1`] = `

  3. Object {

  4.  "createdAt": Any<Date>,

  5.  "id": Any<Number>,

  6.  "name": "LeBron James",

  7. }

  8. `;

虽然样本文件可以生成并且更新十分简单,但是我们不应该随意的修改样本文件。 当对比出错时,首先要考虑是不是被测试代码问题, 只有确定是期望发生变化时才去更新样本,需要把样本文件与代码同等重视起来。做代码审查时,也需要审查样本文件的变化。

快速测试非常适合用来测试 React 组件的渲染逻辑。 React 提供了测试套件用于创建 React 渲染树,由于将 React 渲染树最终转化为 dom 结构或 native 组件的操作是由 ReactDom/ReactNative 完成的,可以认为这一步是可靠的。 因此只需要对比 React 渲染树的快照样本便可以对渲染逻辑进行验证。示例如下:

(https://github.com/facebook/jest/blob/master/examples/snapshot/__tests__/link.react.test.js)

 
           
  1. import React from 'react';

  2. import Link from '../Link.react';

  3. import renderer from 'react-test-renderer';

  4. it('renders correctly', () => {

  5.  const tree = renderer

  6.    .create(<Link page="http://www.facebook.com">Facebook</Link>)

  7.    .toJSON();

  8.  expect(tree).toMatchSnapshot();

  9. });


 
           
  1. exports[`renders correctly 1`] = `

  2. <a

  3.  className="normal"

  4.  href="http://www.facebook.com"

  5.  onMouseEnter={[Function]}

  6.  onMouseLeave={[Function]}

  7. >

  8.  Facebook

  9. </a>

  10. `;

使用快照测试验证 Redux 逻辑更加方便,reducer、actionCreator 返回值都是一个单纯的键值对对象,直接生成快照样本然后对比验证十分方便。如下示例:

 
           
  1. // reducer.js

  2. export const initialState = {

  3.  error: null,

  4.  isLoading: false,

  5.  data: null

  6. };

  7. export default function loadData(state = initialState, action) {

  8.  switch (action.type) {

  9.    case 'failure':

  10.      return {...state, error: action.error, isLoading: false};

  11.    case 'request':

  12.      return {...state, isLoading: true};

  13.    case 'success':

  14.      return {...state, isLoading: false, data: action.data};

  15.    default:

  16.      return state;

  17.  }

  18. }


 
           
  1. // reducer.test.js

  2. import reducer from './reducer';

  3. test('load data success', () => {

  4.  const action = { type: 'success', data: [1, 2, 3]};

  5.  const initState = reducer(undefined, {});

  6.  expect(reducer(initState, action)).toMatchSnapshot();

  7. });


 
           
  1. // Jest Snapshot v1, https://goo.gl/fbAQLP

  2. exports[`load data success 1`] = `

  3. Object {

  4.  "data": Array [

  5.    1,

  6.    2,

  7.    3,

  8.  ],

  9.  "error": null,

  10.  "isLoading": false,

  11. }

  12. `;

九、总结

以上内容主要是对 Jest 的主要功能介绍,更详细的文档可以参考 Jest 官网(https://jestjs.io/)。 Jest 是一款优秀的单元测试工具,其内置的快照测试更加简化了一些常见场景测试工作。 Jest 对 React 的支持十分完善,React 本身就是使用 Jest 做单元测试的,仅凭这一点便可使其成为基于 React 前端工程的首选单测框架。 最后祝愿大家在以后的工作中简单快速的完成单元测试,完成高质量工程代码。

参考

  1. https://jestjs.io

  2. https://blog.callstack.io/unit-testing-react-native-with-the-new-jest-i-snapshots-come-into-play-68ba19b1b9fe

  3. https://reactjs.org/docs/test-utils.html

  4. https://airbnb.io/enzyme/

 
Qunar技术沙龙 更多文章 react-router v4 源码分析 机器学习之 scikit-learn 开发入门(5) 提高网页设计里文本的易读性 机器学习之 scikit-learn 开发入门(4) 机器学习之 scikit-learn 开发入门(3)
猜您喜欢 ios简介:为什么叫ios而不再是iPhone OS 艾玛,大数据还能这么解释!!! 学编程,你不能学会了游泳再下水 Archive IPA文件 端午节你是吃清水粽呢还是吃肉粽呢?