手把手教你写好一个React组件单元测试
前言
单元测试是保证代码质量的常见手段之一,对于 React 来说,使用 Jest + Enzyme 这样的组合是目前比较主流的技术选型,本文记录了作者在对 React 组件做单元测试的过程中得到的一些实践经验,手把手教你如何搭建测试环境,并编写一个完备的 React 组件单元测试
summary
- Part1:测试工程搭建
- use cra
- without cra
- Part2:测试 React 组件
- 组件渲染是否符合预期
- 事件点击的回调函数是否正确执行
- 业务埋点函数是否被正常调用
- 异步接口请求
- setimeout 等异步操作是否按预期执行
- Part3:优化
- 放到同一个文件夹下
- 封装测试中的通用代码
- setupTests 文件使用
- manual mock
完整代码可以从 GitHub 仓库 coderzzp/react-jest-enzyme 获取
Part1: 测试工程搭建
Setup with Create React App
setp1 - 创建工程
npx create-react-app test-with-cra
step2 - 添加enzyme 等包
yarn add enzyme enzyme-adapter-react-16 -D
step3 - 在 src/setupTest.js中配置enzyme
// src/setupTests.js
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
configure({ adapter: new Adapter() });
Done!
可以在terminal中使用 yarn test 开始测试了
Setup without Create React App
setp1 - 配置 setupfile
在package.json文件中声明jest 配置,注意 jest 这个 key 要放在 name 正下方
{
"name": "my-react-app",
"jest": {
"setupFiles": [
"./setupTest"
}
step2 - 添加enzyme ,jest 等包
yarn add enzyme enzyme-adapter-react-16 jest -D
Step3 - 在根目录下创建 setupTest.js 文件
// src/setupTests.js
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
configure({ adapter: new Adapter() });
step4 - 在package.json文件中配置script
{
"scripts": {
"test": "jest"
}
PS: 对于babel 配置的写法,取决于具体项目,可以查阅 babel 官方文档 来获取更多详细信息
Done!
Part2: 测试 React 组件
如下是一个React 组件,它包含了一些我们业务中经常出现的业务逻辑,例如点击事件,异步接口等等
import React from 'react';
import fetchData from './services'
import tracker from './tracker'
export default class Button extends React.Component{
state = {
buttonName:'buttonDefaultName',
data:{}, // 服务端数据
constructor(props){
super(props)
// 埋点
tracker.page('button init')
componentWillMount(){
this.timer = setTimeout(() =>{
this.setState({
buttonName:'buttonAfter3seconds'
},3000)
async componentDidMount(){
const res = await fetchData()
this.setState({data:res})
onButtonClick = () =>{
this.props.onButtonClick && this.props.onButtonClick()
render(){
const {buttonName} = this.state
return <div>
<div className="button" onClick={this.onButtonClick}>{this.props.buttonName || buttonName}</div>
我们的测试目的会围绕这几个关键点:
- 组件是否符合预期的渲染了?
- 事件点击的回调函数是否正确执行了?
- 业务埋点函数是否被正常调用了?
- 异步接口请求如何校验?
- setimeout 等异步后的操作如何校验?
1. 组件是否符合预期的渲染了
不同的外界条件会造成不同的 UI 渲染结果,例如props不同,UI渲染结果也是不同的
propsA → Component Render → renderResultA
propsB → Component Render → renderResultB
我们可以通过Enzyme去渲染组件,再通过snapshot能力对 renderResultA / B 进行一个快照
在 button 组件同级新增
__test__/button1.test.js
文件
//__test__/button1.test.js
// snapshot
import React from 'react'
import { shallow } from 'enzyme';
describe('Button 组件测试', () => {
it('渲染正常', () => {
const Button = require('../button').default
// enzyme的shallow方法用于渲染组件
const componentWrapper = shallow(<Button />);
expect(componentWrapper.html()).toMatchSnapshot();
it('props传入buttonName,渲染正常', () => {
const Button = require('../button').default
const componentWrapper = shallow(<Button buttonName={'mockButtonName'}/>);
expect(componentWrapper.html()).toMatchSnapshot();
之后执行yarn test ,jest会自动为你生成快照文件,生成不同条件下的结果
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Button 组件测试 props传入buttonName,渲染正常 1`] = `"<div><div class=\\"button\\">mockButtonName</div></div>"`;
exports[`Button 组件测试 渲染正常 1`] = `"<div><div class=\\"button\\">buttonDefaultName</div></div>"`;
那么当你下次改动组件的渲染逻辑时,snapshot会提醒你组件渲染与之前不一致了,如果是你预期内的改动,可以更新你的snapshot文件,如果不是预期内的改动,那么你就需要看看是哪里出了问题
2. 事件点击的回调函数是否正确执行了
对于组件中的一些事件行为,可以通过Enzyme 渲染出的组件可以模拟,并断言结果
Component Render → Event simulate → expect result
//__test__/button2.test.js
// simulate click
import React from 'react'
import { shallow } from 'enzyme';
describe('Button 组件测试', () => {
it('click点击事件测试,mockfn校验', () => {
// 生成一个mockfn,用于校验点击后的props.func是否被调用
const mockClick = jest.fn()
const Button = require('../button').default
const componentWrapper = shallow(<Button onButtonClick={mockClick}/>);
// 模拟点击事件
componentWrapper.find('.button').at(0).simulate('click')
// 校验mockfn被调用
expect(mockClick).toHaveBeenCalled();
jest.fn() 用来生成mock函数,可以通过mockClick 这个句柄去判断函数的调用情况
enzyme提供了类似于jquery的dom选择器,并通过 simulate(event) 的方式来模拟事件
3. 业务埋点函数是否被正常调用了
我们可以看到组件通过模块的方式引入了一个 tracker 函数,用于在组件初始化的时候触发埋点行为,那如何验证tracker是否被正常调用呢?
使用jest.domock() 来mock 模块
mock module → Component Render → expect module tobe called
//__test__/button3.test.js
// jest.doMock
import React from 'react'
import { shallow } from 'enzyme';
describe('Button 组件测试', () => {
it('校验埋点是否被正常调用',() => {
// 生成一个mock函数
const page = jest.fn()
// 声明mock tracker这个模块,并在第二个参数传入mock方法
jest.doMock('../tracker',()=>{
return {page}
const Button = require('../button').default
shallow(<Button />);
// mock函数被调用,并且参数是 'button init'
expect(page).toHaveBeenCalledWith('button init')
使用jest.doMock() 来 mock 引入的模块
4. 异步接口请求
我们已经学会了mock module,对于异步的接口请求,如何校验我们拿到的数据呢?
jest 已经支持了async/await
//__test__/button4.test.js
// mock async function
import React from 'react'
import { shallow } from 'enzyme';
describe('Button 组件测试', () => {
it('接口测试',async () => {
jest.doMock('../services',()=>{
return ()=>{
return 'mockdata'
const Button = require('../button').default
const componentWrapper = await shallow(<Button />);
// 通过enzyme渲染的组件可以通过 .state() 方法拿到组件 state 状态
expect(componentWrapper.state('data')).toEqual('mockdata')
由于是在 componentDidMount 中的异步方法,我们需要在渲染前使用 await ,这样才能保证断言中的组件是已经拿到接口数据的
5.setimeout 等异步操作是否按预期执行了
我们可以看到componentWillMount 生命周期中,组件三秒后的state状态会发生改变,那么如何测试到三秒后的状态呢?在测试中等三秒吗?显然不是,我们可以使用jest 提供的faketimer 来帮我们快进时间
jest.useFakeTimers() → Component render → jest.runAllTimers() → expect result
//__test__/button5.test.js
// mocktimer
import React from 'react'
import { shallow } from 'enzyme';
jest.useFakeTimers()
describe('Button 组件测试', () => {
it('3秒后渲染正常', () => {
const Button = require('../button').default
const componentWrapper = shallow(<Button />);
// 快速执行所有的 macro-task (eg. setTimeout(), setInterval())
jest.runAllTimers()
expect(componentWrapper.html()).toMatchSnapshot();
React组件的测试点基本就是围绕以上几个角度,jest 提供了多种 断言api ,可以在对应的场景中使用
Part3:优化
放到同一个文件夹下
之前的测试代码都是针对button组建的测试,它们显然都需要放到一个测试文件下,我们可以尝试将上面的测试代码都放到一个文件下
describe('Button 组件测试', () => {
it('测试1xxx...', () => {
it('测试2xxx...', () => {
more it...
但如果直接这么做你会发现,代码并不会通过测试,原因是在当上一个测试执行后,这个模块会被缓存起来,组件缓存的module,props,都有可能会对其他测试用例产生影响,所以我们通常会在 beforEach 这个钩子函数中使用 jest.resetModules() 来消除不同测试用例之间的影响
describe('Button 组件测试', () => {
beforeEach(()=>{
jest.resetModules();
it('测试1xxx...', () => {
more it...
常见的钩子还有: afterEach,beforeAll,afterAll ,会在测试的不同声明周期执行, 详见
封装测试中的通用代码
观察上面的代码,最常见的复用代码就是组件引入 & 生成代码,因此我们可以把这部分代码封装起来
// 通用代码
const Button = require('../button').default
const componentWrapper =shallow(<Button onButtonClick={mockClick}/>);
可以封装为 generateComponent 使用,可以设置initprops和需要覆盖的props
const generateComponent = props => {
const initProps = {};
const Button = require('../button').default
return shallow(<Button {...initProps } {...props} />);
这样我们在每个it语句开头前,可以使用 generateComponent 来渲染并拿到组件
it('渲染正常', () => {
const componentWrapper = generateComponent()
expect(componentWrapper.html()).toMatchSnapshot();
setupTests 文件使用
setupTests.js 文件会在测试开始前执行,你可以通过它来配置一些全局变量
例如,在 setupTests.js 配置全局引入React 和 shallow 方法,这样一来,我们的测试代码都不需要再重复引入这两个变量
// setupTests.js
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import React from 'react'
import { shallow } from 'enzyme';
global.React = React
global.shallow = shallow
configure({ adapter: new Adapter() });
配置后我们便可以去掉测试代码最上方的 React和shallow 模块
// import React from 'react'
// import { shallow } from 'enzyme';
Manual mock
我们已经学习了通过 jest.doMock 来模拟掉组件中的引入的其他模块,对于一些常见的需要被mock掉的模块,例如数据库操作、fs 等模块,我们可以使用jest 提供的Manual mock,它可以让你在测试组件时去默认引入你对这些三方模块的mock文件
- Mocking user modules
以常用的埋点 tracker 模块为例,在 tacker 模块同级目录下增加
__mocks__
文件
// __mocks__/tracker.js
const tracker = {
page:jest.fn()
export default tracker
对应的测试埋点代码为:
// 在测试文件头部声明 jest.mock('../tracker'),表示我们使用manual mock 来模拟tracker这个文件
jest.mock('../tracker')
describe('Button 组件测试', () => {
it('校验埋点是否被正常调用',() => {
// 获取 mock 函数 page 的句柄
const {page} = require('../tracker').default
generateComponent()
expect(page).toHaveBeenCalledWith('button init')
- Mocking Node modules
对于node_modules中的模块,例如我们需要mock 掉 fs 模块,如下是我们的示例目录
.