前言

前面的文章已经介绍过了前端的一些常见的测试工具,以及测试的必要性,那么今天就来学习一下Jest这个测试框架。

Jest简介

Jest是Fackbook开发的一个开源的Js的单元测试工具,在Jasmine基础之上演变开发而来。

Why Jest

市面上已经有了那么多成熟的测试工具,比如Mocha,Jasmine,Qunit等等,为什么要选择Jest呢?我觉得Jest相比于别的测试工具有着以下的一些优点:

  • 安装配置简单,非常容易上手,几乎是零配置的,通过npm 命令安装就可以直接运行了
  • 内置了常用的测试工具,比如自带断言、测试覆盖率工具等等,不用再安装别的测试工具
  • Jest可以利用其特有的快照测试功能,通过比对 UI 代码生成的快照文件,实现对 React 等常见框架的自动测试。
  • Jest的测试用例是并行执行的,而且只执行发生改变的文件所对应的测试,测试速度比较快。
  • 目前在 Github 上其 star 数已经破万;而除了 Facebook 外,业内其他公司也开始从其它测试框架转向 Jest ,未来 Jest 的发展趋势仍会比较迅猛。

Getting Started

安装

yarn add --dev jest
npm install --save-dev jest

然后新建一个需要进行测试的文件sum.js

function sum(a, b) {
  return a + b;
}
module.exports = 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"
  }
}

运行测试:

Finally, run yarn test and Jest will print this message:
PASS  ./sum.test.js
✓ adds 1 + 2 to equal 3 (5ms)

Jest配置

有三种方法可以对Jest进行配置:

  • package.json中指定jest字段进行配置:
{
  "name": "my-project",
  "jest": {
    "verbose": true
  }
}
  • 在项目根目录新建jest.config.js进行配置:
module.exports = {
  verbose: true,
};
  • 在命令行中使用--config指定配置Json文件,注意不能包含jest字段:
{
  "bail": true,
  "verbose": true
}

常见配置项:

  • automock [boolean][default:false] -- 是否自动mock引入的模块
  • browser [boolean][default:false] -- 解析模块时遵循package.json中Browserify的browser字段
  • globals [object] [default: {}] -- 测试环境中所需的环境变量
  • runner [string] [default: "jest-runner"] -- 自定义测试运行器,例如:jest-runner-eslintjest-runner-mochajest-runner-tscjest-runner-prettier
  • testEnvironment [string] [Default: "jsdom"] -- 测试运行的环境,默认是通过Jsdom生成的浏览器环境,你也可以使用node来指定node环境。
  • verbose [boolean] [default: false] -- 指示每个测试用例是否报告状态
  • 更多配置项请参考https://facebook.github.io/jest/docs/en/configuration.html

Jest CLI

你也可以直接使用命令行运行Jest命令:

jest [--options]

例子:

jest   //运行所有测试
jest path/to/my-test.js  //测试指定文件
jest -o  //测试git/hu中改动的没有提交的文件
jest --findRelatedTests path/to/fileA.js path/to/fileB.js  //测试相关的文件
jest -t name-of-spec  //运行describe或者test中符合名字的测试用例
jest --watch       //runs jest -o by default
jest --watchAll    //runs all tests  

常见的options

  • --bail缩写-b一旦测试用例失败立刻退出
  • --cache是否开启缓存,默认是true,取消缓存使用--no-cache,如果你想要观测缓存的话,使用--showConfig然后看cacheDirectory的值,你还可以使用--clearCache清除缓存
  • --colors强制开启输出高亮
  • --config=<path>缩写-c,指定配置文件
  • --coverage输出测试覆盖率
  • --expand缩写-e显示全部的diffs和错误而不是修补
  • --json打印出测试结果的Json形式
  • --notify激活通知选项
  • --onlyChanged缩写-o,只针对改动的文件运行测试,只在git/hg仓库中,并且只有静态依赖时有用
  • --verbose分层显示每个独立的测试用例
  • --version缩写-v输出版本号
  • 更多请参考https://facebook.github.io/jest/docs/en/cli.html

Matchers

Jest中的Matchers类似于断言库里面的断言判断。下面是一些常见的Matchers:

  • toBe()判断是否是预期的值(不可用于浮点数,浮点数请用toBeCloseTo)
  • toBeCloseTo(number, numDigits)判断浮点数运算是否相等
  • toEqual()判断两个对象是否相等
  • any(constructor)匹配任何由给定构造器生成的对象
  • toBeNull()匹配Null
  • toBeUndefined()匹配undefined
  • toBeDefined()检查是否定义
  • toBeTruthy()不关心值是多少,只关心转换为布尔值以后是否为真
  • toBeFalsy()不关心值是多少,只关心转换为布尔值以后是否为假
  • toBeGreaterThan()大于
  • toBeGreaterThanOrEqual()大于等于
  • toBeLessThan()小于
  • toBeLessThanOrEqual()小于等于
  • toMatch()匹配正则
  • toContain()判断数组里是否包含某个元素
  • toThrow()函数调用后是否会抛出错误
  • toHaveBeenCalled()函数是否被调用
  • not取反
  • resolves获取promise对象的resolves值
  • 更多请参考https://facebook.github.io/jest/docs/en/expect.html

测试异步代码

Jest有4种方法可以测试异步的代码:

  1. 回调函数
    test语句中加入done参数,然后在回调函数中测试语句结束后执行done()即可:
test('the data is peanut butter', done => {
  function callback(data) {
    expect(data).toBe('peanut butter');
    done();
  }

  fetchData(callback);
});
  1. Promises
    只要在test语句中直接返回一个promise即可,如果promise状态为rejected,则测试用例自动失败。
test('the data is peanut butter', () => {
  expect.assertions(1);
  return fetchData().then(data => {
    expect(data).toBe('peanut butter');
  });
});

你也可以使用catch来期望rejected的出现

test('the fetch fails with an error', () => {
  expect.assertions(1);
  return fetchData().catch(e => expect(e).toMatch('error'));
});

需要注意的是,需要添加expect.assertions(number)来验证断言的次数,不添加的话,则无法使测试用例自动失败。
3. .resolves / .rejects

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');
});
  1. Async/Await
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');
  }
});

async/await还可以和resolves/rejects组合使用

test('the data is peanut butter', async () => {
  expect.assertions(1);
  await expect(fetchData()).resolves.toBe('peanut butter');
});
test('the fetch fails with an error', async () => {
  expect.assertions(1);
  await expect(fetchData()).rejects.toMatch('error');
});

钩子函数

beforeEach()  -- 每个test执行之前执行
afterEach()   -- 每个test执行之后执行
beforeAll()   -- 在所有test执行之前执行一次
afterAll()    -- 在所有test执行之后执行一次

默认地,这些钩子函数的作用域是整个文件,你也可以使用describe来进行分组,外部的before先于describe内部的before运行,after后于describe内部的after运行。下面的例子可以很清楚帮助理解钩子函数运行的先后:

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

Jest会先执行所有的describe然后再按顺序执行test:

describe('outer', () => {
  console.log('describe outer-a');
  describe('describe inner 1', () => {
    console.log('describe inner 1');
    test('test 1', () => {
      console.log('test for describe inner 1');
      expect(true).toEqual(true);
    });
  });
  console.log('describe outer-b');
  test('test 1', () => {
    console.log('test for describe outer');
    expect(true).toEqual(true);
  });
  describe('describe inner 2', () => {
    console.log('describe inner 2');
    test('test for describe inner 2', () => {
      console.log('test for describe inner 2');
      expect(false).toEqual(false);
    });
  });
  console.log('describe outer-c');
});
// describe outer-a
// describe inner 1
// describe outer-b
// describe inner 2
// describe outer-c
// test for describe inner 1
// test for describe outer
// test for describe inner 2

Mock Functions

在写单元测试的时候我们通常会根据接口来Mock接口的实现,比如你要测试某个类中的某个方法,而这个方法又依赖了外部的一些接口的实现,从单元测试的角度来说我只关心测试的方法的内部逻辑,却并不关注与当前类本身依赖的实现,所以我们通常会Mock掉依赖接口的返回,因为我们的测试重点在于特定的方法,在Jest中同样提供了Mock的功能。

Mock 函数可以轻松地测试代码之间的连接——这通过擦除函数的实际实现,捕获对函数的调用 ( 以及在这些调用中传递的参数) ,在使用 new 实例化时捕获构造函数的实例,或允许测试时配置返回值的形式来实现。Jest中有两种方式的Mock Function,一种是利用Jest提供的Mock Function创建,另外一种是手动创建来覆写本身的依赖实现。

假设我们要测试函数 forEach 的内部实现,这个函数为传入的数组中的每个元素调用一个回调函数,代码如下:

function forEach(items, callback) {
  for (let index = 0; index < items.length; index++) {
    callback(items[index]);
  }
}

为了测试此函数,我们可以使用一个 mock 函数,然后检查 mock 函数的状态来确保回调函数如期调用。

const mockCallback = jest.fn();
forEach([0, 1], mockCallback);
// The mock function is called twice
expect(mockCallback.mock.calls.length).toBe(2);
// The first argument of the first call to the function was 0
expect(mockCallback.mock.calls[0][0]).toBe(0);
// The first argument of the second call to the function was 1
expect(mockCallback.mock.calls[1][0]).toBe(1);

几乎所有的Mock Function都带有 .mock的属性,它保存了此函数被调用的信息。 .mock 属性还追踪每次调用时 this的值,所以也让检视 this 的值成为可能:

const myMock = jest.fn();
const a = new myMock();
const b = {};
const bound = myMock.bind(b);
bound();
console.log(myMock.mock.instances);
// > [ <a>, <b> ]

在测试中,需要对函数如何被调用,或者实例化做断言时,这些 mock 成员变量很有帮助意义︰

// The function was called exactly once
expect(someMockFunction.mock.calls.length).toBe(1);
// The first arg of the first call to the function was 'first arg'
expect(someMockFunction.mock.calls[0][0]).toBe('first arg');
// The second arg of the first call to the function was 'second arg'
expect(someMockFunction.mock.calls[0][1]).toBe('second arg');
// This function was instantiated exactly twice
expect(someMockFunction.mock.instances.length).toBe(2);
// The object returned by the first instantiation of this function
// had a `name` property whose value was set to 'test'
expect(someMockFunction.mock.instances[0].name).toEqual('test');

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

如果你需要定义一个模拟的函数,它从另一个模块中创建的默认实现,mockImplementation方法非常有用︰

// foo.js
module.exports = function() {
  // some implementation;
};
// test.js
jest.mock('../foo'); // this happens automatically with automocking
const foo = require('../foo');
// foo is a mock function
foo.mockImplementation(() => 42);
foo();
// > 42

当你需要多个函数调用产生不同的结果时,可以使用 mockImplementationOnce 方法:

const myMockFn = jest
  .fn(() => 'default')
  .mockImplementationOnce(() => 'first call')
  .mockImplementationOnce(() => 'second call');
console.log(myMockFn(), myMockFn(), myMockFn(), myMockFn());
// > 'first call', 'second call', 'default', 'default'

对于有通常链接的方法(因此总是需要返回this)的情况,我们有一个语法糖的API以.mockReturnThis()函数的形式来简化它,它也位于所有模拟器上:

const myObj = {
  myMethod: jest.fn().mockReturnThis(),
};
// is the same as
const otherObj = {
  myMethod: jest.fn(function() {
    return this;
  }),
};

你也可以给你的Mock Function起一个准确的名字,这样有助于你在测试错误的时候在输出窗口定位到具体的Function

const myMockFn = jest
  .fn()
  .mockReturnValue('default')
  .mockImplementation(scalar => 42 + scalar)
  .mockName('add42');

最后,为了更简单地说明如何调用mock函数,我们为您添加了一些自定义匹配器函数:

// The mock function was called at least once
expect(mockFunc).toBeCalled();
// The mock function was called at least once with the specified args
expect(mockFunc).toBeCalledWith(arg1, arg2);
// The last call to the mock function was called with the specified args
expect(mockFunc).lastCalledWith(arg1, arg2);
// All calls and the name of the mock is written as a snapshot
expect(mockFunc).toMatchSnapshot();

这些匹配器是真的只是语法糖的常见形式的检查 .mock 属性。 你总可以手动自己如果是更合你的口味,或如果你需要做一些更具体的事情︰

// The mock function was called at least once
expect(mockFunc.mock.calls.length).toBeGreaterThan(0);
// The mock function was called at least once with the specified args
expect(mockFunc.mock.calls).toContain([arg1, arg2]);
// The last call to the mock function was called with the specified args
expect(mockFunc.mock.calls[mockFunc.mock.calls.length - 1]).toEqual([
  arg1,
  arg2,
]);
// The first arg of the last call to the mock function was `42`
// (note that there is no sugar helper for this specific of an assertion)
expect(mockFunc.mock.calls[mockFunc.mock.calls.length - 1][0]).toBe(42);
// A snapshot will check that a mock was invoked the same number of times,
// in the same order, with the same arguments. It will also assert on the name.
expect(mockFunc.mock.calls).toEqual([[arg1, arg2]]);
expect(mockFunc.mock.getMockName()).toBe('a mock name');

Jest平台

您可以挑选Jest的特定功能,并将它们作为独立软件包使用:

  • jest-changed-files 匹配git/hg仓库中改动的文件
const {getChangedFilesForRoots} = require('jest-changed-files');
// print the set of modified files since last commit in the current repo
getChangedFilesForRoots(['./'], {
  lastCommit: true,
}).then(result => console.log(result.changedFiles));
  • jest-diff 可视化差异对比
const diff = require('jest-diff');
const a = {a: {b: {c: 5}}};
const b = {a: {b: {c: 6}}};
const result = diff(a, b);
// print diff
console.log(result);
 Expected
 Received
  Object {
    "a": Object {
      "b": Object {
       "c": 5,
       "c": 6,
      },
    },
  }
  • jest-docblock 提取解析注释,导出各种功能来操作注释块内的数据。
const {parseWithComments} = require('jest-docblock');
const code = `
/**
 * This is a sample
 *
 * @flow
 */
 console.log('Hello World!');
`;
const parsed = parseWithComments(code);
// prints an object with two attributes: comments and pragmas.
console.log(parsed);
{ comments: '/**\nThis is a sample\n\n/\n \n console.log(\'Hello World!\');',
  pragmas: { flow: '' } }
  • jest-get-type 获取原生的js的数据类型
const getType = require('jest-get-type');
const array = [1, 2, 3];
const nullValue = null;
const undefinedValue = undefined;
// prints 'array'
console.log(getType(array));
// prints 'null'
console.log(getType(nullValue));
// prints 'undefined'
console.log(getType(undefinedValue));
  • jest-validate 验证用户提交的配置项
  • jest-worker 执行并行任务
  • pretty-format 格式美化
const prettyFormat = require('pretty-format');
const val = {object: {}};
val.circularReference = val;
val[Symbol('foo')] = 'foo';
val.map = new Map([['prop', 'value']]);
val.array = [-0, Infinity, NaN];
console.log(prettyFormat(val));
Object {
  "array": Array [
    -0,
    Infinity,
    NaN,
  ],
  "circularReference": [Circular],
  "map": Map {
    "prop" => "value",
  },
  "object": Object {},
  Symbol(foo): "foo",
}

Es6 Class Mocks

ES6的类是一个构造函数的语法糖,所以你可以使用mock函数来mock ES6的类。下面我们将创建两个类一个是SoundPlayer类,一个SoundPlayerConsumer类依赖了SoundPlayer类。

// sound-player.js
export default class SoundPlayer {
  constructor() {
    this.foo = 'bar';
  }
  playSoundFile(fileName) {
    console.log('Playing sound file ' + fileName);
  }
}
// sound-player-consumer.js
import SoundPlayer from './sound-player';
export default class SoundPlayerConsumer {
  constructor() {
    this.soundPlayer = new SoundPlayer();
  }
  playSomethingCool() {
    const coolSoundFileName = 'song.mp3';
    this.soundPlayer.playSoundFile(coolSoundFileName);
  }
}

下面介绍4种方法来创建ES6的Mock:

  1. 自动mock

调用jest.mock('./sound-player')方法会返回一个自动的mock,你可以用来监听构造函数和它所有的方法,它会替换构造函数和方法并且总是会返回undefined,方法的调用会存储在theAutomaticMock.mock.instances[index].methodName.mock.calls

import SoundPlayer from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';
jest.mock('./sound-player'); // SoundPlayer is now a mock constructor
beforeEach(() => {
  // Clear all instances and calls to constructor and all methods:
  SoundPlayer.mockClear();
});
it('We can check if the consumer called the class constructor', () => {
  const soundPlayerConsumer = new SoundPlayerConsumer();
  expect(SoundPlayer).toHaveBeenCalledTimes(1);
});
it('We can check if the consumer called a method on the class instance', () => {
  // Show that mockClear() is working:
  expect(SoundPlayer).not.toHaveBeenCalled();
  const soundPlayerConsumer = new SoundPlayerConsumer();
  // Constructor should have been called again:
  expect(SoundPlayer).toHaveBeenCalledTimes(1);
  const coolSoundFileName = 'song.mp3';
  soundPlayerConsumer.playSomethingCool();
  // mock.instances is available with automatic mocks:
  const mockSoundPlayerInstance = SoundPlayer.mock.instances[0];
  const mockPlaySoundFile = mockSoundPlayerInstance.playSoundFile;
  expect(mockPlaySoundFile.mock.calls[0][0]).toEqual(coolSoundFileName);
  // Equivalent to above check:
  expect(mockPlaySoundFile).toHaveBeenCalledWith(coolSoundFileName);
  expect(mockPlaySoundFile).toHaveBeenCalledTimes(1);
});
  1. 手动mock

你也可以创建一个_mock_文件夹,可以自己定义mock函数的实现。

// __mocks__/sound-player.js
// Import this named export into your test file:
export const mockPlaySoundFile = jest.fn();
const mock = jest.fn().mockImplementation(() => {
  return {playSoundFile: mockPlaySoundFile};
});
export default mock;

然后导入mock函数:

// sound-player-consumer.test.js
import SoundPlayer, {mockPlaySoundFile} from './sound-player';
import SoundPlayerConsumer from './sound-player-consumer';
jest.mock('./sound-player'); // SoundPlayer is now a mock constructor
beforeEach(() => {
  // Clear all instances and calls to constructor and all methods:
  SoundPlayer.mockClear();
  mockPlaySoundFile.mockClear();
});
it('We can check if the consumer called the class constructor', () => {
  const soundPlayerConsumer = new SoundPlayerConsumer();
  expect(SoundPlayer).toHaveBeenCalledTimes(1);
});
it('We can check if the consumer called a method on the class instance', () => {
  const soundPlayerConsumer = new SoundPlayerConsumer();
  const coolSoundFileName = 'song.mp3';
  soundPlayerConsumer.playSomethingCool();
  expect(mockPlaySoundFile).toHaveBeenCalledWith(coolSoundFileName);
});
  1. 调用jest.mock()并传递一个工厂函数参数

你可以使用jest.mock(path, moduleFactory)来创建一个mock函数,但是这个工厂函数必须要返回一个mock函数,也就是higher-order function (HOF)

import SoundPlayer from './sound-player';
const mockPlaySoundFile = jest.fn();
jest.mock('./sound-player', () => {
  return jest.fn().mockImplementation(() => {
    return {playSoundFile: mockPlaySoundFile};
  });
});

使用工厂函数参数的不足之处在于,jest.mock()函数会在一开始就被调用,所以你无法一开始就定义一个变量然后在工厂函数中使用。
4. #### 使用mockImplementation()或者mockImplementationOnce()来改变mock
你可以使用mockImplementation()或者mockImplementationOnce()在钩子函数中稍后来改变mock函数的实现:

import SoundPlayer from './sound-player';
jest.mock('./sound-player');
describe('When SoundPlayer throws an error', () => {
  beforeAll(() => {
    SoundPlayer.mockImplementation(() => {
      return {
        playSoundFile: () => {
          throw new Error('Test error');
        },
      };
    });
  });
  it('Should throw an error when calling playSomethingCool', () => {
    const soundPlayerConsumer = new SoundPlayerConsumer();
    expect(() => soundPlayerConsumer.playSomethingCool()).toThrow();
  });
});

整合Webpack

只需要在package.json对jest字段进行一些配置即可:

// package.json
{
  "jest": {
    "modulePaths": ["/shared/vendor/modules"],
    "moduleFileExtensions": ["js", "jsx"],
    "moduleDirectories": ["node_modules", "bower_components", "shared"],
    "moduleNameMapper": {
      "^react(.*)$": "<rootDir>/vendor/react-master$1",  //webpack中的alias字段
      "^config$": "<rootDir>/configs/app-config.js",  //webpack中的alias字段
      "\\.(css|less)$": "<rootDir>/__mocks__/styleMock.js", //静态资源
      "\\.(gif|ttf|eot|svg)$": "<rootDir>/__mocks__/fileMock.js"//静态资源
    }
  }
}

对应静态资源文件的mock文件:

// __mocks__/styleMock.js
module.exports = {};
// __mocks__/fileMock.js
module.exports = 'test-file-stub';

迁移

如果你使用的是 AVA, Chai, Expect.js (by Automattic), Jasmine, Mocha, proxyquire, Should.js或者Tape这些测试工具,想要迁移到Jest也很简单,只需要安装 jest-codemods 即可。

npm install -g jest-codemods

然后在项目根目录运行:

jest-codemods

参考资料