前端测试框架Jest入门教程
前言
前面的文章已经介绍过了前端的一些常见的测试工具,以及测试的必要性,那么今天就来学习一下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-eslint
,jest-runner-mocha
,jest-runner-tsc
,jest-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()
匹配NulltoBeUndefined()
匹配undefinedtoBeDefined()
检查是否定义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种方法可以测试异步的代码:
- 回调函数
在test
语句中加入done
参数,然后在回调函数中测试语句结束后执行done()
即可:
test('the data is peanut butter', done => {
function callback(data) {
expect(data).toBe('peanut butter');
done();
}
fetchData(callback);
});
- 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');
});
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:
-
自动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);
});
-
手动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);
});
-
调用
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