用Jest和Enzyme测试React组件

3,333 阅读5分钟

前言

测试是应用生产过程中不可缺少的一个环节,开发人员在编码时总有考虑不周全或者出错的情况,而测试则是通过对比实际结果与预期结果来找出问题和缺陷,从而确保软件的质量。本文主要介绍了在最近在工作中用JestEnzyme来测试React 组件的过程和容易踩坑的地方。

测试种类

对于一个Web网站来说,测试的种类主要分为以下3种:

  • 单元测试: 测试单个函数或者类,提供输入,确保输出和预期的一样。单元测试的粒度要尽可能小,不要考虑其他类和模块的实现。
  • 集成测试: 测试整个流程或者某组件能够按预期的运行,用来覆盖跨模块的过程。同时也要包括一些反面用例。
  • 功能测试: 站在产品的角度测试各个场景,通过操作浏览器或者网站,忽略内部实现细节和结构,确保和预期的行为一样。

测试框架

市面上现在有很多测试工具,公司里采用Umijs作为脚手架快速搭建了一个React应用,而Umi内部采用了Dva作为数据流管理,同时也自动配置了Jest测试框架。

Jest测试框架由Facebook所推荐,其优点是运行快性能好,并且涵盖了测试所需的多种功能模块(包括断言,模拟函数,比较组件的快照snapshot,运行测试,生成测试结果和覆盖率等),配置简单方便,适合大项目的快速测试。

React组件的测试

测试React组件我们采用Enzyme工具库,它提供3种组件渲染方式:

  1. Shallow:不会渲染子组件
  2. Mount: 渲染子组件,同时包含生命周期函数如componentDidMount
  3. Render: 渲染子组件,但不会包含生命周期,同时可用的API也会减少比如setState()

一般情况下用shallow和mount的情况比较多。

被Connect包裹的组件

有些组件被Connect包裹起来,这种情况不能直接测,需要建立一个Provider和传入一个store,这种过程比较痛苦,最好是将去掉Connect后的组件 export出来单独测,采用shallow的渲染方法,仅测该组件的逻辑。

例如被测的组件如下:

export class Dialog extends Component {
    ...
}
export default connect(mapStateToProps, mapDispatch)(Dialog)

那么在测试文件中, 可以这样初始化一个控件:

import {Dialog} from '../dialog'
function setup(propOverrides) {
  const props = Object.assign(
    {
      state:{}
      actions:{},
    },
    propOverrides,
  )

  const enzymeWrapper = shallow(<Dialog  {...props} />)
  return {
    props,
    enzymeWrapper,
  }
}

需和子组件和原生DOM元素交互的组件

有的组件,需要测试和原生DOM元素的交互,比如要测点击原生button元素,是否触发当前的组件的事件,或者需要测试和子组件的交互时,这时候用需要用mount来渲染。

例如,我的Editor组件是这样:

export default class Editor extends Component {
  constructor(props) {
    super(props)
    this.state = {
      onClickBtn: null,
    }
  }
  handleSubmit = ({ values, setSubmitting }) => {
    const { onClickBtn } = this.state
    this.props.actions.createInfo(values, onClickBtn)
  }
  handleCancel = () => {
    ...
  }
  setOnClickBtn(name) {
    this.setState({
      onClickBtn: name,
    })
  }
  render() {
    return (
      <Form onSubmit={this.handleSubmit}>
        {({ handleChange }) => {
          return (
            <div className="information-form">
                <Input name={FIELD_ROLE_NAME} onChange={handleChange}
                />
                <Input name={FIELD_ROLE_KEY} onChange={handleChange}
                />
              <div>
                <Button type="button" onClick={this.handleCancel}> Cancel </Button>
                <Button type="submit" primary onClick={() => this.setOnClickBtn('create')} > Create </Button>
                <Button type="submit" primary onClick={() => this.setOnClickBtn('continue')} > Create and Continue </Button>}
              </div>
            </div>
          )
        }}
      </Form>
    )
  }
}

此时Form的children是个function,要测试表单中按钮点击事件,如果只用shallow,是无法找到Form中children的元素的,因此这里采用mount方式将整个dom渲染,可直接模拟type为submit属性的那个button的点击事件。 然后测试点击该button是否完成了2个事件:handleSubmitsetOnclickBtn

有人会想到模拟form的submit事件,但在mount的情况下,模拟button的click事件同样可以触发onSubmit事件。

由于submit过程要涉及子控件的交互,其过程具有一定的不确定性,此时需要设置一个timeout,延长一段时间再来判断submit内的action是否被执行。

it('should call create role action when click save', () => {
    const preProps = {
      actions: {
        createInfo: jest.fn(),
      }
    }
    const { props, enzymeWrapper } = setup(preProps)
    const nameInput = enzymeWrapper.find('input').at(0)
    nameInput.simulate('change', { target: { value: 'RoleName' } })

    const keyInput = enzymeWrapper.find('input').at(1)
    keyInput.simulate('change', { target: { value: 'RoleKey' } })

    const saveButton = enzymeWrapper.find('button[type="submit"]').at(0)
    saveButton.simulate('click')
    expect(enzymeWrapper.state().onClickBtn).toBe('save')
    setTimeout(() => {
      expect(props.actions.createInfo).toHaveBeenCalled()
    }, 500)
  })

但是用mount来渲染也有容易让人失误的地方,比如说要找到子组件,可能需要多层.children()才能找到。在单元测试中,应尽量采用shallow渲染,测试粒度尽可能减小。

含有Promise的情况

有的组件的函数逻辑中会含有Promise,其返回结果带有不确定性,例如以下代码段中的auth.handleAuthenticateResponse,传入的参数是一个callback函数,需要根据auth.handleAuthenticateResponse的处理结果是error还是正常的result来处理自己的内部逻辑。

 handleAuthentication = () => {
    const { location, auth } = this.props
    if (/access_token|id_token|error/.test(location.search)) {
      auth.handleAuthenticateResponse(this.handleResponse)
    }
  }

  handleResponse = (error, result) => {
    const { auth } = this.props
    let postMessageBody = null
    if (error) {
      postMessageBody = error
    } else {
      auth.setSession(result)
      postMessageBody = result
    }
    this.handleLogicWithState(postMessageBody)
  }

在测试时,可用jest.fn()模拟出auth.handleAuthenticateResponse函数,同时让它返回一个确定的结果。

const preProps = {
  auth: {
    handleAuthenticateResponse: jest.fn(cb => cb(errorMsg))
  }
}
setup(preProps)

相关API

enzyme: airbnb.io/enzyme/

Jest: jestjs.io/docs/en/api

自留问题

  1. 使用mount测试一个包含子组件的父组件,以及父子组件的交互过程时,这种测试叫做UT测试还是CT组件测试?
  2. 组件snapshot测试是什么?有无必要?如何测?