关于react.js:那些年错过的React组件单元测试下

39次阅读

共计 12260 个字符,预计需要花费 31 分钟才能阅读完成。

👨‍🌾 写在后面

上篇文章咱们曾经理解了前端单元测试的背景和根底的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} = Select
const {RangePicker} = DatePicker
const {confirm} = Modal


export 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 context
const 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 context
const 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 值会随之变动

增加测试代码:

// 测试组件 state
it("产品 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 参数

增加测试代码:

// 测试组件 props
it("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 单元测试,外面曾经列举的比拟具体了,我就不班门弄斧了。

正文完
 0