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

👨‍🌾 写在后面

上篇文章咱们曾经理解了前端单元测试的背景和根底的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 单元测试,外面曾经列举的比拟具体了,我就不班门弄斧了。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理