手把手教你写好一个React组件单元测试

手把手教你写好一个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> 

我们的测试目的会围绕这几个关键点:

  1. 组件是否符合预期的渲染了?
  2. 事件点击的回调函数是否正确执行了?
  3. 业务埋点函数是否被正常调用了?
  4. 异步接口请求如何校验?
  5. 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 模块,如下是我们的示例目录

.