概述
所谓单元测试,就是对每个单元进行的测试,一般针对的是函数、类或单个组件,不涉及系统和集成,单元测试是软件测试的基础测试,一个完备的软件系统都会涉及到单元测试。
目前,Javascript的测试工具很多,但是针对React的测试主要使用的是Facebook推出的Jest框架,Jest是基于Jasmine的JavaScript测试框架,具有上手容易、快速、可靠的特点,是React.js默认的单元测试框架。相比其他的测试框架,Jest具有如下的一些特点:
- 适应性:Jest是模块化、可扩展和可配置的;
- 沙箱和快速:Jest虚拟化了JavaScript的环境,能模拟浏览器,并且并行执行;
- 快照测试:Jest能够对React 树进行快照或别的序列化数值快速编写测试,提供快速更新的用户体验;
- 支持异步代码测试:支持promises和async/await;
- 自动生成静态分析结果:不仅显示测试用例执行结果,也显示语句、分支、函数等覆盖率。
环境搭建
安装Jest
首先,在项目目录下使用下面的命令安装Jest。
npm install --save-dev jest
//或者
yarn add --dev jest
如果你使用的是react-native init命令行方式来创建的RN项目,且RN版本在0.38以上,则无需手动安装,系统在生成项目的时候会自动添加依赖。
"scripts": {
"test": "jest"
},
"jest": {
"preset": "react-native"
}
配置Babel
现在很多的项目都使用es6及以上版本编写,为了兼容老版本,我们可以使用Babel来将es5的语法转换为es6。使用Babel前,我们需要使用如下的命令来安装Babel。
yarn add --dev babel-jest babel-core regenerator-runtime
说明:如果使用的是Babel 的version 7则需要安装babel-jest, babel-core@^7.0.0-bridge.0 和 @babel/core,安全命令如下:
yarn add --dev babel-jest babel-core@^7.0.0-bridge.0 @babel/core regenerator-runtime
然后在项目的根目录里添加 .babelrc 文件,在文件中配置如下react-native脚本内容。
{
"presets": ["react-native"],
"sourceMaps":true // 用于对齐堆栈,精准的定位单元测试中的问题
}
如果是自动生成的, .babelrc 文件的配置脚本如下:
{
"presets": ["module:metro-react-native-babel-preset"]
}
此时,需要将上面的presets配置修改为 "presets": ["react-native"]。
完整配置
为了方便查看, 下面是package.json文件的完整配置:
{
"name": "jestTest",
"version": "0.0.1",
"private": true,
"scripts": {
"start": "node node_modules/react-native/local-cli/cli.js start",
"test": "jest"
},
"dependencies": {
"react-native": "0.55.4",
"react": "^16.6.0",
"react-dom": "^16.6.0"
},
"devDependencies": {
"babel-core": "^6.26.3",
"babel-jest": "^23.6.0",
"jest": "23.6.0",
"metro-react-native-babel-preset": "0.48.3",
"react-test-renderer": "16.5.0",
"regenerator-runtime": "^0.12.1"
},
"jest": {
"preset": "react-native",
"transform": {
"^.+\\.js$": "babel-jest"
},
"transformIgnorePatterns": [
"node_modules/(?!(react-native)/)"
]
}
}
说明:如果报AccessibilityInfo错误,请注意react-naitve的版本号,因为react-naitve的版本和其他库存在一些兼容问题,请使用0.55.4及以下稳定版本。
Cannot find module 'AccessibilityInfo' (While processing preset: "/Users/xiangzhihong029/Documents/rn/jestTest/node_modules/react-native/Libraries/react-native/react-native-implementation.js")
Enzyme
Enzyme 是 Airbnb 公司开源的测试工具库,是react-addons-test-utils的封装的产品,它模拟了 jQuery 的 API,非常直观并且易于使用和学习,提供了一些与众不同的接口和几个方法来减少测试的样板代码,方便你判断、操纵和遍历 React Components 的输出,并且减少了测试代码和实现代码之间的耦合。相比react-addons-test-utils,enzyme的API 就一目了然,下表是两个框架常用的函数的对比。
Enzyme提供了三种渲染方法:
shallow
shallow 方法就是对官方的 Shallow Rendering 的封装,浅渲染在将一个组件作为一个单元进行测试的时候非常有用,可以确保你的测试不会去间接断言子组件的行为。shallow 方法只会渲染出组件的第一层 DOM 结构,其嵌套的子组件不会被渲染出来,从而使得渲染的效率更高,单元测试的速度也会更快。例如:
import { shallow } from 'enzyme'
describe('Enzyme Shallow', () => {
it('App should have three <Todo /> components', () => {
const app = shallow(<App />)
expect(app.find('Todo')).to.have.length(3)
})
}
mount
mount 方法则会将 React 组件渲染为真实的 DOM 节点,特别是在依赖真实的 DOM 结构必须存在的情况下,比如说按钮的点击事件。
完全的 DOM 渲染需要在全局范围内提供完整的 DOM API, 这也就意味着它必须在至少“看起来像”浏览器环境的环境中运行,如果不想在浏览器中运行测试,推荐使用 mount 的方法是依赖于一个名为 jsdom 的库,它本质上是一个完全在 JavaScript 中实现的 headless 浏览器。
mount渲染方式的示例如下:
import { mount } from 'enzyme'
describe('Enzyme Mount', () => {
it('should delete Todo when click button', () => {
const app = mount(<App />)
const todoLength = app.find('li').length
app.find('button.delete').at(0).simulate('click')
expect(app.find('li').length).to.equal(todoLength - 1)
})
})
render
render 方法则会将 React 组件渲染成静态的 HTML 字符串,返回的是一个 Cheerio 实例对象,采用的是一个第三方的 HTML 解析库 Cheerio。这个 CheerioWrapper 可以用于分析最终结果的 HTML 代码结构,它的 API 跟 shallow 和 mount 方法的 API 都保持基本一致。
import { render } from 'enzyme'
describe('Enzyme Render', () => {
it('Todo item should not have todo-done class', () => {
const app = render(<App />)
expect(app.find('.todo-done').length).to.equal(0)
expect(app.contains(<div className="todo" />)).to.equal(true)
})
})
Jest单元测试
简单示例
首先,我们在项目的根目录新建一个名为__test__的文件夹,然后编写一个组件,例如:
import React, {Component} from 'react';
import {
Text, View,
} from 'react-native';
export default class JestTest extends Component{
render() {
return(<View />)
}
}
然后,我们在__test__文件夹下编写一个名为jest.test.js的文件,代码如下:
import React from 'react';
import JestTest from '../src/JestTest';
import renderer from 'react-test-renderer';
test('renders correctly', () => {
const tree = renderer.create(<JestTest/>).toJSON();
expect(tree).toMatchSnapshot();
});
使用命令 “yarn jest” ,系统就会开始执行单元测试,如果没有任何错误,将会显示PASS相关的信息。
当然,上面的例子并没有涉及到任何的业务逻辑,只是介绍了下在React Native中如何使用Jest进行单元测试。
生成快照测试
快照测试是第一次运行测试的时候在不同情况下的渲染结果(挂载前)保存的一份快照文件,后面每次再运行快照测试时,都会和第一次的比较,除非使用“npm test -- -u”命令重新生成快照文件。
为了测试快照测试,我们先新建一个带有逻辑的组件。例如:
import React, {Component} from 'react';
import {
Text, View,
Button
} from 'react-native';
export default class JestTest extends Component{
constructor() {
super();
this.state = {liked: false};
this.handleClick = this.handleClick.bind(this);
}
handleClick() {
return this.setState({
liked: !this.state.liked
});
}
render() {
const text = this.state.liked ? 'like' : 'not liked';
return (<Text onClick={this.handleClick}>
You {text} this.Click to toggle.
</Text>);
}
}
上面的组件拥有三种状态,初始状态,点击状态,以及再次被点击的状态,所以在测试文件中,我们分别生成三种状态的快照,快照测试文件的代码如下:
import React from 'react';
import renderer from 'react-test-renderer';
import JestTest from "../src/JestTest";
describe('<JestTest/>', () => {
it('Snapshot', () => {
const component = renderer.create(<JestTest/>);
let snapshot = component.toJSON();
expect(snapshot).toMatchSnapshot();
snapshot.props.onClick();
snapshot = component.toJSON();
expect(snapshot).toMatchSnapshot();
snapshot.props.onClick();
snapshot = component.toJSON();
expect(snapshot).toMatchSnapshot()
});
});
然后,在控制台运行yarn jest命令,就会看到在__tests___snapshots_目录下看到快照测试,快照测试文件的代码如下:
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<JestTest/> Snapshot 1`] = `
<Text
accessible={true}
allowFontScaling={true}
ellipsizeMode="tail"
onClick={[Function]}
>
You
not liked
this.Click to toggle.
</Text>
`;
exports[`<JestTest/> Snapshot 2`] = `
<Text
accessible={true}
allowFontScaling={true}
ellipsizeMode="tail"
onClick={[Function]}
>
You
like
this.Click to toggle.
</Text>
`;
exports[`<JestTest/> Snapshot 3`] = `
<Text
accessible={true}
allowFontScaling={true}
ellipsizeMode="tail"
onClick={[Function]}
>
You
not liked
this.Click to toggle.
</Text>
`;
如果需要更新快照文件,执行yarn test -- -u命令。
DOM测试
DOM测试主要测试组件生成的 DOM 节点是否符合预期,比如响应事件之后,组件的属性与状态是否符合预期。DOM 测试 依赖于官方的 TestUtil,所以需要安装react-addons-test-utils依赖库,安装的时候注意版本的兼容问题。不过在实战过程中,我发现react-addons-test-utils会报很多错误,并且官方文档也不是很友好。
这里推荐使用airbnb开源的Enzyme 脚手架,Enzyme是由 airbnb 开发的React单测工具,它扩展了React的TestUtils,并通过支持类似jQuery的find语法可以很方便的对render出来的结果做各种断言,开发体检十分友好。
生成测试报告
使用命令yarn test -- --coverage就可以生成测试覆盖报告。如图:
同时,还会在根目录生成一个名为 coverage 的文件夹,是测试覆盖报告的网页版,包含更多,更详细的信息。
Jest基础语法
匹配器
匹配器用于测试输入输出的值是否符合预期,下面介绍一些常见的匹配器。
普通匹配器
最简单的测试值的方法就是看值是否精确匹配,使用的是toBe(),例如:
test('two plus two is four', () => {
expect(2 + 2).toBe(4);
});
toBe()使用的是JavaScript中的Object.is(),属于ES6中的特性,所以不能检测对象,如果要检测对象的值的话,需要用到toEqual。
test('object assignment', () => {
const data = {one: 1};
data['two'] = 2;
expect(data).toEqual({one: 1, two: 2});
});
Truthiness
在实际的测试中,有时候我们需要明确区分undefined、null和false等情况,而Jest提供的下面的一些规则可以帮我们完成上面的需求。
- toBeNull只匹配null
- toBeUndefined只匹配undefined
- toBeDefine与toBeUndefined相反
- toBeTruthy匹配任何if语句为真
- toBeFalsy匹配任何if语句为假
数字匹配器
toBeGreaterThan():大于
toBeGreaterThanOrEqual():大于或者等于
toBeLessThan():小于
toBeLessThanOrEqual():小于或等于
注:对比两个浮点数是否相等,使用的是toBeCloseTo()而不是toEqual()。
例子:
test('two plus two', () => {
const value = 2 + 2;
expect(value).toBeGreaterThan(3);
expect(value).toBeGreaterThanOrEqual(3.5);
expect(value).toBeLessThan(5);
expect(value).toBeLessThanOrEqual(4.5);
// toBe and toEqual are equivalent for numbers
expect(value).toBe(4);
expect(value).toEqual(4);
});
test('两个浮点数字相加', () => {
const value = 0.1 + 0.2;
//expect(value).toBe(0.3); 这句会报错,因为浮点数有舍入误差
expect(value).toBeCloseTo(0.3); // 这句可以运行
});
字符串
使用toMatch()函数测试字符串,传递的参数需要是正则表达式。例如:
test('there is no I in team', () => {
expect('team').not.toMatch(/I/);
});
test('but there is a "stop" in Christoph', () => {
expect('Christoph').toMatch(/stop/);
});
数组
如果要检测某个字符串是否包含某个字符串或字符,可以使用toContain()。例如:
const list = [
'diapers',
'kleenex',
'trash bags',
'paper towels',
'beer',
];
test('字符串包含', () => {
expect(list).toContain('beer');
});
toThrow
如果想在测试特定函数的时候抛出错误,则可以在它调用的时候可以使用toThrow()。
异步函数
在实际开发过程中,经常会遇到一些异步的JavaScript代码。当有异步方式运行的代码的时候,Jest需要知道当前它测试的代码是否已经完成,然后它才可以转移动另一个测试。也就是说,测试的用例一定要在测试对象结束之后才能够运行。异步测试有多种手段:
回调
回调函数和异步没有必然的联系,回调只是异步的一种调用方式而已。现在假设一个fetchData(call)函数,获取一些数据并在完成的时候调用call(data),我们想要测试返回的数据是不是包含字符串'peanut butter',那么我们可以这样写:
function fetchData(call) {
setTimeout(() => {
call('peanut butter1')
},1000);
}
test('the data is peanut butter', (done) => {
function callback(data) {
expect(data).toBe('peanut butter');
done()
}
fetchData(callback);
});
Promise
Promise表示“承诺将来会执行”的对象,基础内容可以参考廖雪峰的Promise。例如,还是上面的fetchData,我们使用Promise代替回调来实现网络请求。则测试代码写法如下:
test('the data is peanut butter', () => {
expect.assertions(1);
return fetchData().then(data => {
expect(data).toBe('peanut butter');
});
});
上面,我们使用expect.assertions来验证一定数量的断言是否被调用,如果想要Promise被拒绝,我们可以使用.catch方法。
test('the fetch fails with an error', () => {
expect.assertions(1);
return fetchData().catch(e => expect(e).toMatch('error'));
});
Async/Await
Async/Await是一种新的异步请求实现方式,若要编写async测试,只需要在函数前面使用async关键字即可。例如:
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');
}
});
Jest Object
在写测试的时候,我们经常需要进行测试之前做一些准备工作。例如,多次测试重复设置的工作,可以使用beforeEach和afterEach。
beforeEach(() => {
jest.resetModules();
});
test('moduleName 1', () => {
jest.doMock('../moduleName', () => {
return jest.fn(() => 1);
});
const moduleName = require('../moduleName');
expect(moduleName()).toEqual(1);
});
test('moduleName 2', () => {
jest.doMock('../moduleName', () => {
return jest.fn(() => 2);
});
const moduleName = require('../moduleName');
expect(moduleName()).toEqual(2);
});
在某些情况下,如果只需要在文件的开头做一次设置,则可以使用beforeAll和afterAll来处理。
beforeAll(() => {
return initializeCityDatabase();
});
afterAll(() => {
return clearCityDatabase();
});
test('city database has Vienna', () => {
expect(isCity('Vienna')).toBeTruthy();
});
test('city database has San Juan', () => {
expect(isCity('San Juan')).toBeTruthy();
});
作用域
默认情况下,before和after的块可以应用到文件中的每一个测试。此外可以通过describe块来将将测试中的某一块进行分组,当before和after的块在describe块内部的时候,则只适用于该describe块内的测试。例如:
describe('arrayContaining', () => {
const expected = ['Alice', 'Bob'];
it('matches even if received contains additional elements', () => {
expect(['Alice', 'Bob', 'Eve']).toEqual(expect.arrayContaining(expected));
});
it('does not match if received does not contain expected elements', () => {
expect(['Bob', 'Eve']).not.toEqual(expect.arrayContaining(expected));
});
});
Jest测试之Mock
mock测试就是在测试过程中,对于某些不容易构造或者不容易获取的对象,用一个虚拟的对象来创建以便继续进行测试的测试方法。Mock函数通常会提供以下三种特性:
- 捕获函数调用情况;
- 设置函数返回值;
- 改变函数的内部实现
本节,我们主要介绍与 Mock 函数相关的几个API,分别是jest.fn()、jest.spyOn()、jest.mock()。
jest.fn()
jest.fn()是创建Mock函数最简单的方式,如果没有定义函数内部的实现,jest.fn()会返回undefined作为返回值。例如:
// functions.test.js
test('测试jest.fn()调用', () => {
let mockFn = jest.fn();
let result = mockFn(1, 2, 3);
// 断言mockFn的执行后返回undefined
expect(result).toBeUndefined();
// 断言mockFn被调用
expect(mockFn).toBeCalled();
// 断言mockFn被调用了一次
expect(mockFn).toBeCalledTimes(1);
// 断言mockFn传入的参数为1, 2, 3
expect(mockFn).toHaveBeenCalledWith(1, 2, 3);
})
jest.fn()所创建的Mock函数还可以设置返回值,定义内部实现或返回Promise对象。
// functions.test.js
test('测试jest.fn()返回固定值', () => {
let mockFn = jest.fn().mockReturnValue('default');
// 断言mockFn执行后返回值为default
expect(mockFn()).toBe('default');
})
test('测试jest.fn()内部实现', () => {
let mockFn = jest.fn((num1, num2) => {
return num1 * num2;
})
// 断言mockFn执行后返回100
expect(mockFn(10, 10)).toBe(100);
})
test('测试jest.fn()返回Promise', async () => {
let mockFn = jest.fn().mockResolvedValue('default');
let result = await mockFn();
// 断言mockFn通过await关键字执行后返回值为default
expect(result).toBe('default');
// 断言mockFn调用后返回的是Promise对象
expect(Object.prototype.toString.call(mockFn())).toBe("[object Promise]");
})
上面的代码是jest.fn()提供的几个常用的API和断言语句,下面我们在src/fetch.js文件中写一些被测试代码,以更加接近业务的方式来理解Mock函数的实际应用。
需要说明的是,被测试代码中依赖了axios这个常用的请求库和JSONPlaceholder这个上篇文章中提到免费的请求接口,请先在shell中执行npm install axios --save安装依赖。
// fetch.js
import axios from 'axios';
export default {
async fetchPostsList(callback) {
return axios.get('https://jsonplaceholder.typicode.com/posts').then(res => {
return callback(res.data);
})
}
}
我们在fetch.js中封装了一个fetchPostsList方法,该方法请求了JSONPlaceholder提供的接口,并通过传入的回调函数返回处理过的返回值。如果我们想测试该接口能够被正常请求,只需要捕获到传入的回调函数能够被正常的调用即可。例如:
import fetch from '../src/fetch.js'
test('fetchPostsList中的回调函数应该能够被调用', async () => {
expect.assertions(1);
let mockFn = jest.fn();
await fetch.fetchPostsList(mockFn);
// 断言mockFn被调用
expect(mockFn).toBeCalled();
})
jest.mock()
在上一个请求fetch.js文件夹中,我们封装的请求方法可能在其他模块被调用,但有时候我们并不需要进行实际的请求(请求方法已经通过单侧或需要该方法返回非真实数据)。此时,使用jest.mock()去mock整个模块是十分有必要的。
// events.js
import fetch from './fetch';
export default {
async getPostList() {
return fetch.fetchPostsList(data => {
console.log('fetchPostsList be called!');
// do something
});
}
}
然后我们编写一个测试文件,用于测试getPostList请求。
// functions.test.js
import events from '../src/events';
import fetch from '../src/fetch';
jest.mock('../src/fetch.js');
test('mock 整个 fetch.js模块', async () => {
expect.assertions(2);
await events.getPostList();
expect(fetch.fetchPostsList).toHaveBeenCalled();
expect(fetch.fetchPostsList).toHaveBeenCalledTimes(1);
});
测试代码中,我们使用了jest.mock('../src/fetch.js')去mock整个fetch.js模块,如果注释掉这行代码,执行测试脚本时会出现以下报错信息。
jest.spyOn()
jest.spyOn()方法同样可以创建一个mock函数,但是该mock函数不仅能够捕获函数的调用情况,还可以正常的执行被spy的函数。实际上,jest.spyOn()是jest.fn()的语法糖,它创建了一个和被spy的函数具有相同内部代码的mock函数。例如:
上图是之前jest.mock()的示例代码中的正确执行结果的截图,从shell脚本中可以看到console.log('fetchPostsList be called!');这行代码并没有在shell中被打印,这是因为通过jest.mock()后,模块内的方法是不会被jest所实际执行的。这时我们就需要使用jest.spyOn()。
// functions.test.js
import events from '../src/events';
import fetch from '../src/fetch';
test('使用jest.spyOn()监控fetch.fetchPostsList被正常调用', async() => {
expect.assertions(2);
const spyFn = jest.spyOn(fetch, 'fetchPostsList');
await events.getPostList();
expect(spyFn).toHaveBeenCalled();
expect(spyFn).toHaveBeenCalledTimes(1);
})
执行npm run test后,可以看到shell中的打印信息,说明通过jest.spyOn(),fetchPostsList被正常的执行了。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。