React Native单元测试
概述
所谓单元测试,就是对每个单元进行的测试,一般针对的是函数、类或单个组件,不涉及系统和集成,单元测试是软件测试的基础测试,一个完备的软件系统都会涉及到单元测试。
目前,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被正常的执行了。