概念

什么是前端单元测试

为检测特定的目标是否符合标准而采用专用的工具或者方法进行验证,并最终得出特定的结果。

前端单元测试不是:

  • 需要访问数据库的测试不是单元测试
  • 需要访问网络的测试不是单元测试
  • 需要访问文件系统的测试不是单元测试

单元测试的意义

Jest

优点

  • 一站式的解决方案,学习成本更低,上手更快。使用 Jest ,需要一个测试框架(mocha),一个测试运行器(karma),一个断言库(chai),需要一个用来做 spies/stubs/mocks 的工具(sinon 以及 sinon-chai 插件),还需要一个用于测试的浏览器环境(可以是 Chrome 浏览器,也可以用 PhantomJS)。 而使用 Jest 后,只要安装它,全都搞定了。
  • 全面的官方文档,易于学习和使用。Jest 的官方文档很完善,对着文档很快就能上手。而在之前,我需要学习好几个插件的用法,至少得知道 mocha 用处和原理、karma 的配置和命令、chai 的各种断言方法…,这增加了学习成本。
  • 更直观明确的测试信息提示。
  • 方便的命令行工具。

缺点

JSDOM 的一些局限性:因为 Jest 是基于 JSDOM 的,JSDOM 毕竟不是真实的浏览器环境,它在测试过程中其实并不真正的“渲染”组件。这会导致一些问题,例如,如果组件代码中有一些根据实际渲染后的属性值进行计算(比如元素的 clientWidth)就可能出问题,因为 JSDOM 中这些参数通常默认是 0。

安装

使用 yarn 安装 Jest

  1. yarn add --dev jest

npm

  1. npm install --save-dev jest

实例

  1. // sum.js
  2. function sum(a, b){
  3. return a + b;
  4. }
  5. module.exports = sum;
  6. // sum.test.js
  7. const sum = require('./sum');
  8. test('adds 1+2 to equal 3', () => {
  9. expect(sum(1, 2)).toBe(3);
  10. });
  11. // package.json
  12. "scripts": {
  13. "test": "jest"
  14. },

结果

image.png

匹配器(Matchers)

数字

toBe 用于 Object.is 测试确切的相等性。如果要检查对象的值,请 toEqual 改用:

  1. test('object assignment', () => {
  2. const data = {one: 1};
  3. data['two'] = 2;
  4. expect(data).toEqual({one: 1, two: 2});
  5. });

toEqual 递归检查对象或数组的每个字段,必须匹配对象所有的属性才能匹配。

对于比较浮点数相等,使用toBeCloseTo而不是toEqual,因为你不希望测试取决于一个小小的舍入误差。

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

字符串

  1. test('there is no I in team', () => {
  2. expect('team').not.toMatch(/I/);
  3. });
  4. test('but there is a "stop" in Christoph', () => {
  5. expect('Christoph').toMatch(/stop/);
  6. });

toBe 和 toMatch 都可以用于字符串的匹配,区别在于 toBe 匹配的字符必须完全一致,而 toMatch 匹配字符的字串即可。

数组

  1. const shoppingList = [
  2. 'diapers',
  3. 'beer'
  4. ];
  5. test('购物清单里有啤酒', () => {
  6. expect(shoppingList).toContain('beer');
  7. });

其它

如果你想要测试的特定函数抛出一个错误,在它调用时,使用 toThrow

  1. function compileAndroidCode() {
  2. throw new Error('you are using the wrong JDK');
  3. }
  4. test('compiling android goes as expected', () => {
  5. expect(compileAndroidCode).toThrow();
  6. expect(compileAndroidCode).toThrow(Error);
  7. // You can also use the exact error message or a regexp
  8. expect(compileAndroidCode).toThrow('you are using the wrong JDK');
  9. expect(compileAndroidCode).toThrow(/JDK/);
  10. });

测试异步代码

回调

函数 done 会等回调函数执行结束后执行测试。如果 done() 永远不会调用,这个测试将失败。

  1. test('the data is peanut butter', done => {
  2. function callback(data) {
  3. expect(data).toBe('peanut butter');
  4. done();
  5. }
  6. fetchData(callback);
  7. function fetchData(c){
  8. setTimeout(() => {
  9. c('peanut butter');
  10. }, 1000);
  11. }
  12. });

Promise

如果您的代码使用 Promise,还有一个更简单的方法来处理异步测试。只需要从您的测试返回一个 Promise,Jest 会等待这一 Promise 来解决。如果承诺被拒绝,则测试将自动失败。

  1. test('the data is peanut butter', () => {
  2. return fetchData(1).then(data => {
  3. expect(data).toBe('peanut butter');
  4. });
  5. });
  6. function fetchData(data) {
  7. return new Promise((resolve, reject) => {
  8. if (data) {
  9. resolve('peanut butter');
  10. } else {
  11. reject('error');
  12. }
  13. })
  14. }

如果你想要 Promise 被拒绝,使用 .catch 方法。 请确保添加 expect.assertions 来验证一定数量的断言被调用。 否则一个fulfilled态的 Promise 不会让测试失败

  1. test('the data is peanut butter', () => {
  2. return fetchData(0).catch(e => expect(e).toBe('error'));
  3. });
  4. function fetchData(data) {
  5. return new Promise((resolve, reject) => {
  6. if (data) {
  7. resolve('peanut butter');
  8. } else {
  9. reject('error');
  10. }
  11. })
  12. }

.resolves/.rejects

您也可以 .resolves 在 expect 语句中使用匹配器,Jest 将等待该 promise。如果承诺被拒绝,则测试将自动失败。

  1. test('the data is peanut butter', () => {
  2. return expect(fetchData(1)).resolves.toBe('peanut butter');
  3. });
  4. function fetchData(data) {
  5. return new Promise((resolve, reject) => {
  6. if (data) {
  7. resolve('peanut butter');
  8. } else {
  9. reject('error');
  10. }
  11. })
  12. }

确保返回断言 - 如果省略此 return 语句,则测试将在返回的 promise 返回之前完成 fetchData,then() 有机会执行回调。

如果你想要 Promise 被拒绝,使用 .catch 方法。它 .resolves 参照工程匹配器。如果 Promise 被拒绝,则测试将自动失败。

  1. test('the data is peanut butter', () => {
  2. return expect(fetchData(0)).rejects.toBe('error');
  3. });
  4. function fetchData(data) {
  5. return new Promise((resolve, reject) => {
  6. if (data) {
  7. resolve('peanut butter');
  8. } else {
  9. reject('error');
  10. }
  11. })
  12. }

Async/Await

或者,您可以在测试中使用 asyncawait。 若要编写 async 测试,只要在函数前面使用 async 关键字传递到 test。 例如,可以用来测试相同的 fetchData 方案

  1. test('the data is peanut butter', async () => {
  2. expect.assertions(1);
  3. const data = await fetchData(1);
  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(0);
  10. } catch (e) {
  11. expect(e).toBe('error');
  12. }
  13. });
  14. function fetchData(data) {
  15. return new Promise((resolve, reject) => {
  16. if (data) {
  17. resolve('peanut butter');
  18. } else {
  19. reject('error');
  20. }
  21. })
  22. }

钩子函数

一次性设置

在某些情况下,你只需要在文件的开头做一次设置。当这种设置是异步行为时,可能非常恼人,你不太可能一行就解决它。Jest提供 beforeAllafterAll 处理这种情况。

例如,如果 initializeCityDatabaseclearCityDatabase 都返回了promise,城市数据库可以在测试中重用,我们就能把我们的测试代码改成这样:

  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. });

作用域

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

例如,假设我们不仅仅是一个城市数据库,还有一个食品数据库。我们可以为不同的测试做不同的设置:

  1. // Applies to all tests in this file
  2. beforeEach(() => {
  3. return initializeCityDatabase();
  4. });
  5. test('city database has Vienna', () => {
  6. expect(isCity('Vienna')).toBeTruthy();
  7. });
  8. test('city database has San Juan', () => {
  9. expect(isCity('San Juan')).toBeTruthy();
  10. });
  11. describe('matching cities to foods', () => {
  12. // Applies only to tests in this describe block
  13. beforeEach(() => {
  14. return initializeFoodDatabase();
  15. });
  16. test('Vienna <3 sausage', () => {
  17. expect(isValidCityFoodPair('Vienna', 'Wiener Schnitzel')).toBe(true);
  18. });
  19. test('San Juan <3 plantains', () => {
  20. expect(isValidCityFoodPair('San Juan', 'Mofongo')).toBe(true);
  21. });
  22. });

请注意,顶级在块内部 beforeEach 之前执行。它可能有助于说明所有钩子的执行顺序。 beforeEach``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

使用 mock 函数

  1. // test/forEach.test.js
  2. const forEach = require('../forEach');
  3. test('使用 mock 函数', () => {
  4. const mockCallback = jest.fn(x => 42 + x);
  5. forEach([0, 1], mockCallback);
  6. // mock函数被调用两次
  7. expect(mockCallback.mock.calls.length).toBe(2);
  8. // 第一次调用函数的第一个参数是0
  9. expect(mockCallback.mock.calls[0][0]).toBe(0);
  10. // 第二个函数调用的第一个参数是1
  11. expect(mockCallback.mock.calls[1][0]).toBe(1);
  12. // 第一次调用函数的返回值是42
  13. expect(mockCallback.mock.results[0].value).toBe(42);
  14. });
  15. // forEach.js
  16. function forEach(items, callback) {
  17. for (let index = 0; index < items.length; index++) {
  18. callback(items[index]);
  19. }
  20. }
  21. module.exports = forEach;

image.png

快照测试

它的工作原理与普通的单元测试稍有不同。第一次运行测试时,将传递给测试的输出保存到“快照文件”中,而不是执行一些代码并将输出与开发人员提供的值进行比较。然后在将来运行测试时,将输出与快照文件进行比较。如果输出与文件匹配,则测试通过,如果输出与文件不同,则测试失败,Jest将打印一个差异。

快照测试的过程与普通测试略有不同。大多数快照测试看起来都很简单 来自Jest存储库的这个示例:

  1. // Link.react.js
  2. // Copyright 2004-present Facebook. All Rights Reserved.
  3. import React from 'react';
  4. const STATUS = {
  5. NORMAL: 'normal',
  6. HOVERED: 'hovered',
  7. };
  8. export default class Link extends React.Component {
  9. constructor() {
  10. super();
  11. this._onMouseEnter = this._onMouseEnter.bind(this);
  12. this._onMouseLeave = this._onMouseLeave.bind(this);
  13. this.state = {
  14. class: STATUS.NORMAL,
  15. };
  16. }
  17. _onMouseEnter() {
  18. this.setState({class: STATUS.HOVERED});
  19. }
  20. _onMouseLeave() {
  21. this.setState({class: STATUS.NORMAL});
  22. }
  23. render() {
  24. return (
  25. <a
  26. className={this.state.class}
  27. href={this.props.page || '#'}
  28. onMouseEnter={this._onMouseEnter}
  29. onMouseLeave={this._onMouseLeave}>
  30. {this.props.children}
  31. </a>
  32. );
  33. }
  34. }
  35. // Link.react-test.js (partial)
  36. // Copyright 2004-present Facebook. All Rights Reserved.
  37. /* eslint-disable no-unused-vars */
  38. 'use strict'
  39. import React from 'react';
  40. import Link from '../Link.react';
  41. import renderer from 'react-test-renderer';
  42. it('renders correctly', () => {
  43. const tree = renderer.create(
  44. <Link page="http://www.facebook.com">Facebook</Link>
  45. ).toJSON();
  46. expect(tree).toMatchSnapshot();
  47. });

第一次运行测试时,会生成一个快照文件。在这种情况下,运行上一个测试会生成一个如下所示的快照文件:

  1. // Link.react-test.js.snap (partial)
  2. exports[`test renders correctly 1`] = `
  3. <a
  4. className="normal"
  5. href="http://www.facebook.com"
  6. onMouseEnter={[Function bound _onMouseEnter]}
  7. onMouseLeave={[Function bound _onMouseLeave]}>
  8. Facebook
  9. </a>
  10. `;

这为我们期望UI的外观提供了基线。快照在 __tests__ 目录中的文件夹中生成,以便可以将其检入源代码管理中。
下次运行测试时,如果没有任何更改,则测试通过。但是,如果我们改变某些东西(假设我们添加了一个类),那么测试就会失败并向我们展示差异。

  1. // updated link render method
  2. render() {
  3. return (
  4. <a
  5. className={`link-item ${this.state.class}`}
  6. href={this.props.page || '#'}
  7. onMouseEnter={this._onMouseEnter}
  8. onMouseLeave={this._onMouseLeave}>
  9. {this.props.children}
  10. </a>
  11. );
  12. }

image.png

然后,我们可以选择通过运行 jest -u 更新快照来接受此更改,或者更新我们的代码以修复回归。如果我们更新快照文件,测试将再次开始传递。

参考

【1】浅谈前端单元测试
【2】Facebook | Jest 官方文档
【3】testing-with-jest-snapshots-first-impressions
【4】为vue的项目添加单元测试