写在后面

上篇文章咱们曾经理解了前端单元测试的背景和根底的jestapi,本篇文章我会先介绍一下Enzyme,而后联合我的项目中的一个实在组件,来为它编写测试用例。

Enzyme

上一篇中咱们其实曾经简略介绍了enzyme,但这远远不够,在本篇的组件测试用例编写中,咱们有很多中央要用到它,因而这里专门来阐明一下。

Enzyme是由Airbnb开源的一个ReactJavaScript测试工具,使React组件的输入更加容易。EnzymeAPIjQuery操作DOM一样灵便易用,因为它应用的是cheerio库来解析虚构DOM,而cheerio的指标则是做服务器端的jQueryEnzyme兼容大多数断言库和测试框架,如chaimochajasmine等。

对于装置和配置,上一大节曾经有过阐明,这里就不赘述了

罕用函数

enzyme中有几个比拟外围的函数,如下:

  • simulate(event, mock):用来模仿事件触发,event为事件名称,mock为一个event object
  • instance():返回测试组件的实例;
  • find(selector):依据选择器查找节点,selector能够是CSS中的选择器,也能够是组件的构造函数,以及组件的display name等;
  • at(index):返回一个渲染过的对象;
  • text():返回以后组件的文本内容;
  • html(): 返回以后组件的HTML代码模式;
  • props():返回根组件的所有属性;
  • prop(key):返回根组件的指定属性;
  • state():返回根组件的状态;
  • setState(nextState):设置根组件的状态;
  • setProps(nextProps):设置根组件的属性;

渲染形式

enzyme 反对三种形式的渲染:

  • shallow:浅渲染,是对官网的Shallow Renderer的封装。将组件渲染成虚构DOM对象,只会渲染第一层,子组件将不会被渲染进去,因此效率十分高。不须要 DOM 环境, 并能够应用jQuery的形式拜访组件的信息;
  • render:动态渲染,它将React组件渲染成动态的HTML字符串,而后应用Cheerio这个库解析这段字符串,并返回一个Cheerio的实例对象,能够用来剖析组件的html构造;
  • mount:齐全渲染,它将组件渲染加载成一个实在的DOM节点,用来测试DOM API的交互和组件的生命周期,用到了jsdom来模仿浏览器环境。

三种办法中,shallowmount因为返回的是DOM对象,能够用simulate进行交互模仿,而render办法不能够。个别shallow办法就能够满足需要,如果须要对子组件进行判断,须要应用render,如果须要测试组件的生命周期,须要应用mount办法。

渲染形式局部参考的这篇文章

“踩坑之路”开启

组件代码

首先,来看下咱们须要对其进行测试的组件局部的代码:

⚠️ 因为牵扯到外部代码,所以很多中央都打码了。重在演示针对不同类型的测试用例的编写
import { SearchOutlined } from "@ant-design/icons"import {  Button,  Col,  DatePicker,  Input,  message,  Modal,  Row,  Select,  Table,} from "antd"import { connect } from "dva"import { Link, routerRedux } from "dva/router"import moment from "moment"import PropTypes from "prop-types"import React from "react"const { Option } = Selectconst { RangePicker } = DatePickerconst { confirm } = Modalexport class MarketRuleManage extends React.Component {  constructor(props) {    super(props)    this.state = {      productID: "",    }  }  componentDidMount() {    // console.log("componentDidMount生命周期")  }  getTableColumns = (columns) => {    return [      ...columns,      {        key: "operation",        title: "操作",        dataIndex: "operation",        render: (_text, record, _index) => {          return (            <React.Fragment>              <Button                type="primary"                size="small"                style={{ marginRight: "5px" }}                onClick={() => this.handleRuleEdit(record)}              >                编辑              </Button>              <Button                type="danger"                size="small"                onClick={() => this.handleRuleDel(record)}              >                删除              </Button>            </React.Fragment>          )        },      },    ]  }  handleSearch = () => {    console.log("点击查问")    const { pagination } = this.props    pagination.current = 1    this.handleTableChange(pagination)  }  render() {    // console.log("props11111", this.props)    const { pagination, productList, columns, match } = this.props    const { selectedRowKeys } = this.state    const rowSelection = {      selectedRowKeys,      onChange: this.onSelectChange,    }    const hasSelected = selectedRowKeys.length > 0    return (      <div className="content-box marketRule-container">        <h2>XX录入零碎</h2>        <Row>          <Col className="tool-bar">            <div className="filter-span">              <label>产品ID</label>              <Input                data-test="marketingRuleID"                style={{ width: 120, marginRight: "20px", marginLeft: "10px" }}                placeholder="请输出产品ID"                maxLength={25}                onChange={this.handlemarketingRuleIDChange}              ></Input>              <Button                type="primary"                icon={<SearchOutlined />}                style={{ marginRight: "15px" }}                onClick={() => this.handleSearch()}                data-test="handleSearch"              >                查问              </Button>            </div>          </Col>        </Row>        <Row>          <Col>            <Table              tableLayout="fixed"              bordered="true"              rowKey={(record) => `${record.ruleid}`}              style={{ marginTop: "20px" }}              pagination={{                ...pagination,              }}              columns={this.getTableColumns(columns)}              dataSource={productList}              rowSelection={rowSelection}              onChange={this.handleTableChange}            ></Table>          </Col>        </Row>      </div>    )  }MarketRuleManage.prototypes = {  columns: PropTypes.array,}MarketRuleManage.defaultProps = {  columns: [  {      key: "xxx",      title: "产品ID",      dataIndex: "xxx",      width: "10%",      align: "center",    },    {      key: "xxx",      title: "产品名称",      dataIndex: "xxx",      align: "center",    },    {      key: "xxx",      title: "库存",      dataIndex: "xxx",      align: "center",      // width: "12%"    },    {      key: "xxx",      title: "流动有效期开始",      dataIndex: "xxx",      // width: "20%",      align: "center",      render: (text) => {        return text ? moment(text).format("YYYY-MM-DD HH:mm:ss") : null      },    },    {      key: "xxx",      title: "流动有效期完结",      dataIndex: "xxx",      // width: "20%",      align: "center",      render: (text) => {        return text ? moment(text).format("YYYY-MM-DD HH:mm:ss") : null      },    },  ],}const mapStateToProps = ({ marketRuleManage }) => ({  pagination: marketRuleManage.pagination,  productList: marketRuleManage.productList,  productDetail: marketRuleManage.productDetail,})const mapDispatchToProps = (dispatch) => ({  queryMarketRules: (data) =>    dispatch({ type: "marketRuleManage/queryRules", payload: data }),  editMarketRule: (data) =>    dispatch({ type: "marketRuleManage/editMarketRule", payload: data }),  delMarketRule: (data, cb) =>    dispatch({ type: "marketRuleManage/delMarketRule", payload: data, cb }),  deleteByRuleId: (data, cb) =>    dispatch({ type: "marketRuleManage/deleteByRuleId", payload: data, cb }),})export default connect(mapStateToProps, mapDispatchToProps)(MarketRuleManage)

简略介绍一下组件的性能:这是一个被connect包裹的高阶组件,页面展现如下:

咱们要增加的测试用例如下:

1、页面可能失常渲染

2、DOM测试:题目应该为XX录入零碎

3、组件生命周期能够被失常调用

4、组件内办法handleSearch(即“查问”按钮上绑定的事件)能够被失常调用

5、产品 ID 输入框内容更改后,stateproductID值会随之变动

6、MarketRuleManage组件应该承受指定的props参数

测试页面快照

明确了需要,让咱们开始编写第一版的测试用例代码:

import React from "react"import { mount, shallow } from "enzyme"import MarketRuleManage from "../../../src/routes/marketRule-manage"describe("XX录入零碎页面", () => {  // 应用 snapshot 进行 UI 测试  it("页面应能失常渲染", () => {    const wrapper = shallow(<MarketRuleManage />)    expect(wrapper).toMatchSnapshot()  })})

执行npm run test:

npm run test对应的脚本是jest --verbose


报错了:
Either wrap the root component in a <Provider>, or explicitly pass "store" as a prop to "Connect(MarketRuleManage)".意思就是咱们须要给connect包裹的组件传递一个store

通过一番搜寻,我在stackoverflow找到了答案,须要应用redux-mock-store中的configureMockStore来模仿一个假的store。来调整一下测试代码:

import React from "react"➕import { Provider } from "react-redux"➕import configureMockStore from "redux-mock-store"import { mount, shallow } from "enzyme"import MarketRuleManage from "../../../src/routes/marketRule-manage"➕const mockStore = configureMockStore()➕const store = mockStore({➕ marketRuleManage: {➕   pagination: {},➕    productList: [],➕    productDetail: {},➕  },➕})➕const props = {➕  match: {➕    url: "/",➕  },➕}describe("XX录入零碎页面", () => {  // 应用 snapshot 进行 UI 测试  it("页面应能失常渲染", () => {➕    const wrapper = shallow(<Provider store={store}>➕      <MarketRuleManage {...props} />➕   </Provider>)    expect(wrapper).toMatchSnapshot()  })})

再次运行npm run test

ok,第一条测试用例通过了,并且生成了快照目录__snapshots__

测试页面DOM

咱们接着往下,来看第二条测试用例:DOM测试:题目应该为XX录入零碎

批改测试代码:

import React from "react"import { Provider } from "react-redux"import configureMockStore from "redux-mock-store"import { mount, shallow } from "enzyme"import MarketRuleManage from "../../../src/routes/marketRule-manage"const mockStore = configureMockStore()const store = mockStore({  marketRuleManage: {    pagination: {},    productList: [],    productDetail: {},  },})const props = {  match: {    url: "/",  },}describe("XX录入零碎页面", () => {  // 应用 snapshot 进行 UI 测试  it("页面应能失常渲染", () => {    const wrapper = shallow(<Provider store={store}>      <MarketRuleManage {...props} />    </Provider>)    expect(wrapper).toMatchSnapshot()  })  // 对组件节点进行测试  it("题目应为'XX录入零碎'", () => {    const wrapper = shallow(<Provider store={store}>      <MarketRuleManage {...props} />    </Provider>)    expect(wrapper.find("h2").text()).toBe("XX录入零碎")  })})

运行npm run test

纳尼?Method “text” is meant to be run on 1 node. 0 found instead.找不到h2标签?

咱们在开篇介绍enzyme时,晓得它有三种渲染形式,那这里咱们改为mount试试。再次运行npm run test

丑陋,又进去一个新的谬误:Invariant Violation: You should not use <Link> outside a <Router>

一顿搜寻,再次在stackoverflow找到了答案(不得不说 stackoverflow 真香),因为我的我的项目中用到了路由,而这里是须要包装一下的:

import { BrowserRouter } from 'react-router-dom';import Enzyme, { shallow, mount } from 'enzyme';import { shape } from 'prop-types';// Instantiate router contextconst router = {  history: new BrowserRouter().history,  route: {    location: {},    match: {},  },};const createContext = () => ({  context: { router },  childContextTypes: { router: shape({}) },});export function mountWrap(node) {  return mount(node, createContext());}export function shallowWrap(node) {  return shallow(node, createContext());}

这里我把这部分代码提取到了一个独自的routerWrapper.js文件中。

而后咱们批改下测试代码:

import React from "react"import { Provider } from "react-redux"import configureMockStore from "redux-mock-store"import { mount, shallow } from "enzyme"import MarketRuleManage from "../../../src/routes/marketRule-manage"➕import {➕  mountWrap,➕  shallowWithIntlWrap,➕  shallowWrap,➕} from "../../utils/routerWrapper"const mockStore = configureMockStore()const store = mockStore({  marketRuleManage: {    pagination: {},    productList: [],    productDetail: {},  },})const props = {  match: {    url: "/",  },}➕const wrappedShallow = () =>  shallowWrap(    <Provider store={store}>      <MarketRuleManage {...props} />    </Provider>  )➕const wrappedMount = () =>  mountWrap(    <Provider store={store}>      <MarketRuleManage {...props} />    </Provider>  )describe("XX录入零碎页面", () => {  // 应用 snapshot 进行 UI 测试  it("页面应能失常渲染", () => {  const wrapper = wrappedShallow()    expect(wrapper).toMatchSnapshot()  })  // 对组件节点进行测试  it("题目应为'XX录入零碎'", () => {    const wrapper = wrappedMount()    expect(wrapper.find("h2").text()).toBe("XX录入零碎")  })})
⚠️ 留神代码中的图标,➕ 代表新增代码, 代表代码有批改

运行npm run test

报错TypeError: window.matchMedia is not a function,这又是啥谬误啊!!

查阅相干材料,matchMedia是挂载在window上的一个对象,示意指定的媒体查问字符串解析后的后果。它能够监听事件。通过监听,在查问后果发生变化时,就调用指定的回调函数。

显然jest单元测试须要对matchMedia对象做一下mock。通过搜寻,在stackoverflow这里找到了答案:

Object.defineProperty(window, 'matchMedia', {  writable: true,  value: jest.fn().mockImplementation(query => ({    matches: false,    media: query,    onchange: null,    addListener: jest.fn(), // Deprecated    removeListener: jest.fn(), // Deprecated    addEventListener: jest.fn(),    removeEventListener: jest.fn(),    dispatchEvent: jest.fn(),  })),});

把上述代码写到一个独自的matchMedia.js文件中,而后在下面的routerWrapper.js文件中引入:

import { mount, shallow } from "enzyme"import { mountWithIntl, shallowWithIntl } from "enzyme-react-intl"import { shape } from "prop-types"import { BrowserRouter } from "react-router-dom"➕import "./matchMedia"// Instantiate router contextconst router = {  history: new BrowserRouter().history,  route: {    location: {},    match: {},  },}const createContext = () => ({  context: { router },  childContextTypes: { router: shape({}) },})// ...

此时从新运行npm run test


ok,第二条测试用例也顺利通过了~

测试生命周期

来看第三条测试 case:组件生命周期能够被失常调用

应用spyOnmock组件的componentDidMount。增加测试代码:

// 测试组件生命周期it("组件生命周期", () => {  const componentDidMountSpy = jest.spyOn(    MarketRuleManage.prototype,    "componentDidMount"  )  const wrapper = wrappedMount()  expect(componentDidMountSpy).toHaveBeenCalled()  componentDidMountSpy.mockRestore()})

运行npm run test:

用例顺利通过~

记得要在用例最初对mock的函数进行mockRestore()

测试组件的外部函数

接着来看第四条测试 case:组件内办法handleSearch(即“查问”按钮上绑定的事件)能够被失常调用。

增加测试代码:

// 测试组件的外部函数it("组件内办法handleSearch能够被失常调用", () => {  const wrapper = wrappedMount()  const instance = wrapper.instance()  const spyFunction = jest.spyOn(instance, "handleSearch")  instance.handleSearch()  expect(spyFunction).toHaveBeenCalled() // handleSearch被调用了一次  spyFunction.mockRestore()})

执行npm run test:

报错了:Cannot spy the handleSearch property because it is not a function; undefined given instead

没方法,只能搜一下,寻求答案,首先在stackoverflow失去了如下计划:

大抵意思就是要用shallowWithIntl()来包裹一下组件,而后被包裹的组件须要用dive()一下。

我立刻批改了代码,再次运行npm run test,后果仍然是一样的。

没方法,接着搜寻,在enzyme 的#365issue看到了仿佛很靠近的答案:

就是在jest.spyOn()之后对组件进行强制更新:wrapper.instance().forceUpdate()wrapper.update()

接着批改代码、调试,仍然有效。

我,郁闷了。。。

两头也找了很多计划,但都没用。

这时正好在外部文档上看到了一个其余 BU 大佬写的单元测试总结,于是就厚着脸皮去找大佬聊了聊,果不其然,这招很凑效,一语点醒梦中人:你的组件被connect包裹,是一个高阶组件,须要拿instance之前做下find操作,这样能力拿到实在组件的实例。

感激完大佬,我立刻去实际:

// 测试组件的外部函数it("组件内办法handleSearch能够被失常调用", () => {  const wrapper = wrappedMount()  const instance = wrapper.find("MarketRuleManage").instance()  const spyFunction = jest.spyOn(instance, "handleSearch")  instance.handleSearch()  expect(spyFunction).toHaveBeenCalled() // handleSearch被调用了一次  spyFunction.mockRestore()})

急不可待的npm run test:

嗯,测试用例顺利通过,真香!

写完这个用例,我不禁反思:小伙子,根底还是不太行啊

还是要多写多实际才行啊!

测试组件 state

废话少说,咱们来看第五条测试用例:产品 ID 输入框内容更改后,stateproductID值会随之变动

增加测试代码:

// 测试组件stateit("产品ID输入框内容更改后,state中productID会随之变动", () => {  const wrapper = wrappedMount()  const inputElm = wrapper.find("[data-test='marketingRuleID']").first()  const userInput = 1111  inputElm.simulate("change", {    target: { value: userInput },  })  // console.log(  //   "wrapper",  //   wrapper.find("MarketRuleManage").instance().state.productID  // )  const updateProductID = wrapper.find("MarketRuleManage").instance().state    .productID  expect(updateProductID).toEqual(userInput)})

这里其实是模仿用户的输出行为,而后应用simulate监听输入框的change事件,最终判断input的扭转是否能同步到state中。

这个用例其实是有点BDD的意思了

咱们运行npm run test

用例顺利通过~

测试组件 props

终于来到了最初一个测试用例:MarketRuleManage组件应该承受指定的props参数

增加测试代码:

// 测试组件propsit("MarketRuleManage组件应该接管指定的props", () => {  const wrapper = wrappedMount()  // console.log("wrapper", wrapper.find("MarketRuleManage").instance())  const instance = wrapper.find("MarketRuleManage").instance()  expect(instance.props.match).toBeTruthy()  expect(instance.props.pagination).toBeTruthy()  expect(instance.props.productList).toBeTruthy()  expect(instance.props.productDetail).toBeTruthy()  expect(instance.props.queryMarketRules).toBeTruthy()  expect(instance.props.editMarketRule).toBeTruthy()  expect(instance.props.delMarketRule).toBeTruthy()  expect(instance.props.deleteByRuleId).toBeTruthy()  expect(instance.props.columns).toBeTruthy()})

执行npm run test

到这里,咱们所有的测试用例就执行完了~

咱们执行的这 6 条用例根本能够比拟全面的涵盖React组件单元测试了,当然因为咱们这里用的是dva,那么不免也要对model进行测试,这里我放一下一个大佬的dva-example-user-dashboard 单元测试,外面曾经列举的比拟具体了,我就不班门弄斧了。