Jest 是一款 Facebook 开源的 JS 单元测试框架,具有 auto mock、自带 mock API、前端友好(集成JSDOM)、环境隔离等特点和优势。Jest 默认使用 Jasmine 语法,支持直接使用 Promise 和 async/await 进行异步测试,支持对 React 组件进行快照监控, 扩展和集成 Babel 等常用工具集也很方便。目前 Jest 已经在 Facebook 开源的 React, React Native 等前端项目中被做为标配测试框架。
下面简单介绍一些 Jest 比较有用的功能和用法。
Mock
Jest 自带一个 mock 系统,并支持自动和手动 mock。
通常项目中,要测试的文件可能带有很多调用依赖,另外单元测试环境和真实环境可也能存在差异,使得脱离真实环境不能直接运行。我们在写一个测试用例前,如果能对非关键的依赖进行 mock,只约定好最后的返回,就不用再先解决一堆依赖和环境问题,把精力集中在要测试的单元上来编写 test case ,同时也缩短测试用例执行的时间,做到最小化测试。
例如下面这段典型的前端业务代码,涉及到网络请求、DOM操作等多个步骤,不在浏览器环境中是无法直接执行。
./writeUser.js
import $ from 'jquery';
import fetchUser from './fetchUser';
export function bind(){
$('#button').click(() => {
fetchUser((err, user) => { if(err){
alert(err.message);
}else{
$('#nick').text(user.nick);
}
});
});
}
这种情况使用 Jest 的 mock 功能处理起来却很轻松。如果我们开启了 auto mock,所有文件都会被 mock 掉不会被真实执行到。我们只要稍作加工,就可以指定各个文件的行为,并模拟我们想要的情况来进行不同的测试,例如本例中控制 fetchUser 的返回。
而在最后的 DOM 操作上由于有 JSDOM 模拟浏览器环境,我们可以指定不去 mock jQuery,让其正常执行,并且还能用来辅助测试。
./tests/writeUser.test.js
jest.unmock("../writeUser"); //要测试的文件不mockjest.unmock("jquery"); //有JSDOM环境可以用import $ from 'jquery';
import fetchUser from '../fetchUser';
import { bind } from '../writeUser';
describe('拉取成功时', () => {
beforeAll(() => { /* 指定 fetchUser 的行为 */
fetchUser.mockImplementation(cb => {
cb(null, {nick: 'mc-zone'});
}); /* 初始化 Document */
document.body.innerHTML =
'<div id="nick"></div><button id="button"></button>';
});
it('拉取到信息后改写 DOM Text', () => {
bind();
$('#button').click();
expect(fetchUser).toHaveBeenCalled();
expect($('#nick').text()).toEqual('mc-zone');
});
});
最后可以测试执行的结果:
此外,Jest 提供的 mock API 也非常丰富。
常用的 mock 相关 API:
require.requireActual(moduleName)require.requireMock(moduleName)
jest.resetAllMocks()
jest.disableAutomock()
jest.enableAutomock()
jest.fn(?implementation)
jest.isMockFunction(fn)
jest.genMockFromModule(moduleName)
jest.mock(moduleName, ?factory, ?options)
jest.resetModules()
jest.setMock(moduleName, moduleExports)
jest.unmock(moduleName)
在生成了 mock function 后,可以对其行为做各种定制和修改,达到想要的情景:
mockFn.mockClear()
mockFn.mockReset()
mockFn.mockImplementation(fn)
mockFn.mockImplementationOnce(fn)
mockFn.mockReturnThis()
mockFn.mockReturnValue(value)
mockFn.mockReturnValueOnce(value)
在被调用后,mock function 会自动记录每次的调用信息,例如我想拿到第 m 次被调用时的第 n 个参数,就可以通过 mock.calls 来访问到:
var myMock = jest.fn();
myMock('1');
myMock('a', 'b');console.log(myMock.mock.calls);
> [ [1], ['a', 'b'] ]
也可以通过 expert 对 mock function 做调用的断言,就像刚刚对 fetchUser 那样:
expect(fetchUser).toHaveBeenCalled();
可用的断言 API:
.toHaveBeenCalled()
.toHaveBeenCalledWith(arg1, arg2, ...)
.toHaveBeenCalledTimes(number)
.toHaveBeenLastCalledWith(arg1, arg2, ...)
详细的可以看 官网文档 [附1]。
Timer
业务代码中如果有 setTimeout 这样的计时器,在测试过程中如果真实的去执行,可能会严重拖慢整个测试项目的执行时间,设想一个功能有 n 个用例去测试,延时就会被重复 n 倍。
Jest 对所有的 Timer (setTimeout, setInterval, clearTimeout, clearInterval 等)都提供了 mock 和 API,让你可以在测试时反客为主,方便自如的控制它们。例如使用 jest.useFakeTimers() 把遇到的计时器挂起,在必要时再使用 jest.runOnlyPendingTimers() 执行掉已经挂起的计时器。
下面一个官网的 Demo,可以看到在用例不必关心 Timer 执行结果的场景下完全可以 mock 掉:
// timerGame.js'use strict';function timerGame(callback) { console.log('Ready....go!');
setTimeout(() => { console.log('Times up -- stop!');
callback && callback();
}, 1000);
}module.exports = timerGame;// __tests__/timerGame-test.js'use strict';
jest.useFakeTimers();
it('waits 1 second before ending the game', () => { const timerGame = require('../timerGame');
timerGame();
expect(setTimeout.mock.calls.length).toBe(1);
expect(setTimeout.mock.calls[0][1]).toBe(1000);
});
Jest 的 Timer API:
jest.clearAllTimers()
jest.runAllTicks()
jest.runAllTimers()
jest.runTimersToTime(msToRun)
jest.runOnlyPendingTimers()
jest.useFakeTimers()
jest.useRealTimers()
React 支持
为了能够通过测试用例实现对 React 组件的变化做监控,14.0 以后版本的 Jest 提供了 React 组件快照功能(React Tree Snapshot Testing)。可以通过 react-test-renderer,把 React 组件生成快照并暂存下来,在之后跑用例时如果组件结果发生了改变则报错提醒。
例如下面做个简单的例子:
./reactApp.js
import React, { Component } from "react";
export default class App extends Component {
render(){ return ( <div>
<h1>{this.props.title}</h1>
{this.props.children} </div>
)
}
};
./tests/reactApp.test.js
import React from "react";
import App from "../reactApp";
import renderer from 'react-test-renderer';
it("react render", () => { const component = renderer.create( <App title="Hello React" >
<span>test text</span>
</App>
);
let tree = component.toJSON();
expect(tree).toMatchSnapshot();
});
这时运行测试用例,将生成一个 "App" 组件的快照。
如果把上面的 tree 打印出来可以看到是一个 React 组件的 JSON tree。
这时候如果我们改动一下代码:
./reactApp.js
import React, { Component } from "react";
export default class App extends Component {
render(){ return ( <div>
<h1>{this.props.title + "mutate"}</h1>
{this.props.children} </div>
)
}
};
再执行测试用例,将会看到报错:
提示我们组件的结果和上一次保存的快照不同。这样就可以达到监控的目的。
另外如果修改了组件代码,需要更新快照,则带上参数 -u 重新运行一次即可,快照就会更新。
除此之外 Jest 也可以结合 enzyme 更好的在 React 项目中进行测试(enzyme 是 airbnb 开源的一个 React 测试工具,通过 Shallow Rendering 的实现对 React 生成的组件节点进行断言和测试)。要了解更多可以阅读 官方文档 [附3] 和 enzyme [附4] 。
异步支持
如果有使用过 node-tap 之类的老测试框架,在遇到异步情况时候肯定感受过麻烦了。现代的测试框架对异步的支持都是必需的。在 Jest 中也不用像 mocha 那样通过执行 done 来通知异步结束,而是直接返回 Promise 和 async/await 就好。
it('works with promises', () => { return user.getUserName(5)
.then(name => expect(name).toEqual('Paul'));
});
it('works with async/await', async () => { const userName = await user.getUserName(4);
expect(userName).toEqual('Mark');
});
环境隔离
在 Jest 中,不同的测试文件是分开独立执行的,如果担心各种 mock 和 unmock 在不同测试用例之间造成冲突,可以按照分类把用例分开放到不同文件内。Jest 利用了多核 CPU 来并行执行测试文件,并且对环境做了隔离,这一点和 AVA 一样。
控制台输出
另外还有良好的控制台输出,执行顺序调整,代码覆盖率统计等等。
下图为在 react-native 源项目中执行 verbose 的 jest test 时,控制台的实时输出:
Jest 的覆盖率统计:
详细报错定位:
总之 Jest 是一款上手很快,功能齐全,高定制性的测试框架。社区的活跃程度也和其他 Facebook 项目一样,值得一试。
扩展:关于编写可测试的代码
最后再来一个关于写 mock 的实例。
我们都知道保持编写可测试的代码的习惯是非常重要的。可测试性差的代码,在写测试用例时也会花费成倍的时间。例如下面这个例子:
./renderUser.js
import fetch from 'fetch';
export default function(){ return Promise.all([
fetch("http://example.com/getUserInfo?uid=123")
.then(response => response.json())
.then(json => { if(json.code == 0){ return json.data;
}else{ throw new Error(json.message);
}
}),
fetch("http://example.com/getUserLevel?uid=123")
.then(response => response.json())
.then(json => {
if(json.code == 0 && json.data && json.data.level){ return json.data.level;
}else{
throw new Error(json.message);
}
})
])
.then(([userInfo, level]) => { const text = "昵称:" + userInfo.nick + "等级:" + level;
$("#container").text(text);
}).catch(err => {
alert(err)
});
}
这里有对 getUserInfo 和 getUserLevel 两个接口的拉取,测试用例的关注点应是要确保取到正确数据后能够正常写到 DOM 上,应该把网络拉取部分 mock 掉,构造测试数据返回,在当前的代码就是 fetch 部分。 具体如何写 mock 呢?
jest.mock("fetch");
import fetch from "fetch";
fetch.mockImplementation((url, params) => { let data; if(/getUserInfo/.test(url)){
data = {
nick:"Bob"
};
}else if(/getUserLevel/.test(url)){
data = {
level:12
};
} return new Promise((resolve, reject) => {
resolve({
json:() => { return new Promise((_resolve, _reject) => {
_resolve({
code:0,
data:data
});
});
}
});
});
});
it("render", () => { document.body.innerHTML = '<div id="container"></div>'; return renderUser().then(() => {
expect($("#container").text()).toBe("昵称:Bob等级:12");
});
});
看到在现在的情况下,两次类似的 fetch 调用使得需要在 mock 中对不同参数做判断。另外因为在 fetch 的 promise 链上的连续操作,mock 时还要注意实现 response.json() 等操作。
这样的代码不仅显得比较长,单独一个测试用例的 mock 也很长。可以设想如果代码中间的过程再增加,相应的 mock 还要再修改。要怎么写才能够更加方便测试呢?
我们可以把调用的代码稍微封装一下,把网络请求和数据处理相关的内容抽离出去。改写后的 renderUser 模块:
./renderUser.js
import fetchUserInfo from './fetchUserInfo';
import fetchUserLevel from './fetchUserLevel';export default function(){ return Promise.all([
fetchUserInfo({ uid:123 }),
fetchUserLevel({ uid:123 })
])
.then(([user, level]) => { const text = "昵称:" + user.nick + "等级:" + level;
$("#container").text(text);
})
.catch(err => {
alert(err)
});
}
这样再做 mock 测试就很简单:
./tests/renderUser.test.js
jest.mock("../fetchUserInfo");
jest.mock("../fetchUserLevel");
import fetchUserInfo from "../fetchUserInfo";
import fetchUserLevel from "../fetchUserLevel";
import renderUser from "../renderUser";import $ from "jquery";
fetchUserInfo.mockImplementation(params => { const data = {
nick:"Bob"
}; return Promise.resolve(data);
});
fetchUserLevel.mockImplementation(params => { const level = 12; return Promise.resolve(level);
});
it("render", () => {
document.body.innerHTML = '<div id="container"></div>'; return renderUser().then(() => {
expect($("#container").text()).toBe("昵称:Bob等级:12");
});
});
优化一下结构,写出更好测试的代码其实很容易。
最后总结一下,编写可测试的代码,其实可以遵循这几个点来规范:
其他还有很多可以优化的点不再阐述,感兴趣的推荐阅读一下 编写可测试的JavaScript代码[附5] 这本书。
附 文中链接: