Missed unit tests of React components in those years

👨‍🌾 Write in front

In the previous article, we have learned about the background of front-end unit testing and the basic jestapi. In this article, I will first introduce Enzyme, and then write test cases for it combined with a real component in the project.

👨‍🚀 Enzyme

In the last article, we actually briefly introduced enzyme, but this is far from enough. We need to use it in many places in the writing of component test cases in this article, so here is a special explanation.

Enzyme is a React JavaScript testing tool open source by Airbnb, which makes the output of React components easier. Enzyme's API is as flexible and easy to use as jQuery operating DOM, because it uses the cherio library to parse the virtual DOM, and cherio's goal is to do server-side jQuery. Enzyme is compatible with most assertion libraries and testing frameworks, such as chai, mocha, jasmine, etc.

🙋 The installation and configuration have been described in the previous section and will not be repeated here

Common functions

There are several core functions in enzyme, as follows:

  • simulate(event, mock): used to simulate event triggering. Event is the event name and mock is an event object;
  • instance(): returns the instance of the test component;
  • find(selector): find nodes according to selectors. Selectors can be selectors in CSS, constructors of components, and display name s of components;
  • at(index): returns a rendered object;
  • text(): returns the text content of the current component;
  • html(): returns the HTML code form of the current component;
  • props(): returns all properties of the root component;
  • prop(key): returns the specified attribute of the root component;
  • state(): returns the status of the root component;
  • setState(nextState): sets the state of the root component;
  • setProps(nextProps): sets the properties of the root component;

rendering method

enzyme supports rendering in three ways:

  • Shallow: shallow rendering is the encapsulation of the official Shallow Renderer. Rendering a component as a virtual DOM object will only render the first layer, and the sub components will not be rendered, so the efficiency is very high. DOM environment is not required, and jQuery can be used to access the information of components;
  • render: static rendering, which renders the React component into a static HTML string, then parses the string using the cherio library, and returns an instance object of cherio, which can be used to analyze the HTML structure of the component;
  • mount: full rendering, which loads the component rendering into a real DOM node to test the interaction of DOM API and the life cycle of components. jsdom is used to simulate the browser environment.

Among the three methods, share and mount can be simulate d interactively because they return DOM objects, while render cannot. Generally, the share method can meet the requirements. If you need to judge sub components, you need to use render. If you need to test the life cycle of components, you need to use the mount method.

The rendering method is partially referenced This article

🐶 The "road of stepping on the pit" is opened

Component code

First, let's look at the code of the component we need to test:

⚠️ Because it involves internal code, many places are coded. It focuses on the preparation of different types of test cases

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 lifecycle")
  }



  getTableColumns = (columns) => {
    return [
      ...columns,
      {
        key: "operation",
        title: "operation",
        dataIndex: "operation",
        render: (_text, record, _index) => {
          return (
            <React.Fragment>
              <Button
                type="primary"
                size="small"
                style={{ marginRight: "5px" }}
                onClick={() => this.handleRuleEdit(record)}
              >
                edit
              </Button>
              <Button
                type="danger"
                size="small"
                onClick={() => this.handleRuleDel(record)}
              >
                delete
              </Button>
            </React.Fragment>
          )
        },
      },
    ]
  }


  handleSearch = () => {
    console.log("Click query")
    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 Input system</h2>
        <Row>
          <Col className="tool-bar">
            <div className="filter-span">
              <label>product ID</label>
              <Input
                data-test="marketingRuleID"
                style={{ width: 120, marginRight: "20px", marginLeft: "10px" }}
                placeholder="Please enter a product ID"
                maxLength={25}
                onChange={this.handlemarketingRuleIDChange}
              ></Input>
              <Button
                type="primary"
                icon={<SearchOutlined />}
                style={{ marginRight: "15px" }}
                onClick={() => this.handleSearch()}
                data-test="handleSearch"
              >
                query
              </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: "product ID",
      dataIndex: "xxx",
      width: "10%",
      align: "center",
    },
    {
      key: "xxx",
      title: "Product name",
      dataIndex: "xxx",
      align: "center",
    },
    {
      key: "xxx",
      title: "stock",
      dataIndex: "xxx",
      align: "center",
      // width: "12%"
    },
    {
      key: "xxx",
      title: "Start of activity validity period",
      dataIndex: "xxx",
      // width: "20%",
      align: "center",
      render: (text) => {
        return text ? moment(text).format("YYYY-MM-DD HH:mm:ss") : null
      },
    },
    {
      key: "xxx",
      title: "End of activity validity",
      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)

Briefly introduce the functions of the component: This is a high-level component wrapped by connect. The page is shown as follows:

The test cases we want to add are as follows:

1. The page can be rendered normally

2. DOM test: the title should be XX entered into the system

3. The component life cycle can be called normally

4. The method handleSearch (that is, the event bound on the query button) in the component can be called normally

5. After the content of the product ID input box is changed, the productID value in state will change accordingly

6. The MarketRuleManage component should accept the specified props parameter

Test page snapshot

After clarifying the requirements, let's start writing the first version of test case code:

import React from "react"
import { mount, shallow } from "enzyme"


import MarketRuleManage from "../../../src/routes/marketRule-manage"

describe("XX Enter system page", () => {

  // UI testing with snapshot
  it("The page should render normally", () => {
    const wrapper = shallow(<MarketRuleManage />)
    expect(wrapper).toMatchSnapshot()
  })

})

Execute npm run test:

The script corresponding to npm run test is jest --verbose


Error reported:
Either wrap the root component in a < provider >, or explicitly pass "store" as a prop to "connect (marketrulemanage)".

After a search, I was stackoverflow When you find the answer, you need to use the configureMockStore in Redux mock store to simulate a fake store. To adjust the test code:

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 Enter system page", () => {

  // UI testing with snapshot
  it("The page should render normally", () => {
➕    const wrapper = shallow(<Provider store={store}>
➕      <MarketRuleManage {...props} />
➕   </Provider>)
    expect(wrapper).toMatchSnapshot()
  })

})

Run npm run test again:

ok, the first test case passed and the snapshot directory was generated__ snapshots__.

Test page DOM

Let's move on to the second test case: DOM test: the title should be XX input system.

Modify test code:

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 Enter system page", () => {

  // UI testing with snapshot
  it("The page should render normally", () => {
    const wrapper = shallow(<Provider store={store}>
      <MarketRuleManage {...props} />
    </Provider>)
    expect(wrapper).toMatchSnapshot()
  })

  // Test component nodes
  it("The title should read'XX Input system'", () => {
    const wrapper = shallow(<Provider store={store}>
      <MarketRuleManage {...props} />
    </Provider>)
    expect(wrapper.find("h2").text()).toBe("XX Input system")
  })

})

Run npm run test:

what? Method "text" is mean to be run on 1 node. 0 found instead?

When we introduced the enzyme at the beginning, we knew that it has three rendering methods. Here, let's try mount instead. Run npm run test again:

Beautiful, another new error: invariant violation: you should not use < link > outside a < router >

A search, again in stackoverflow I found the answer (I have to say that stack overflow is really fragrant), because routing is used in my project, and it needs to be packaged here:

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());
}

Here I extract this part of the code into a separate routerWrapper.js file.

Then we modify the test code:

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 Enter system page", () => {

  // UI testing with snapshot
  it("The page should render normally", () => {
🔧  const wrapper = wrappedShallow()
    expect(wrapper).toMatchSnapshot()
  })

  // Test component nodes
  it("The title should read'XX Input system'", () => {
 🔧   const wrapper = wrappedMount()
    expect(wrapper.find("h2").text()).toBe("XX Input system")
  })

})

⚠️ Notice the icon in the code, ➕ Represents a new code, 🔧 It means that the code has been modified

Run npm run test:

Error TypeError: window.matchMedia is not a function, what kind of error is this!!

Referring to relevant information, matchMedia is an object mounted on the window, which represents the result of parsing the specified media query string. It can listen for events. By listening, the specified callback function is called when the query result changes.

Obviously, the jest unit test needs to mock the matchMedia object. After searching, in stackoverflow Here's the answer:

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(),
  })),
});

Write the above code into a separate matchMedia.js file, and then import it into the routerWrapper.js file above:

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({}) },
})
// ...

Run npm run test again:


ok, the second test case also passed ~

Test lifecycle

Let's look at the third test case: the component life cycle can be called normally

Use spyOn to mock the componentDidMount of the component. Add test code:

// Test component lifecycle
it("Component lifecycle", () => {
  const componentDidMountSpy = jest.spyOn(
    MarketRuleManage.prototype,
    "componentDidMount"
  )
  const wrapper = wrappedMount()

  expect(componentDidMountSpy).toHaveBeenCalled()

  componentDidMountSpy.mockRestore()
})

Run npm run test:

Use case successfully passed ~

Remember to perform mockRestore() on the function of mock at the end of the use case

Internal functions of test components

Next, let's look at the fourth test case: the method handleSearch (that is, the event bound on the "query" button) in the component can be called normally.

Add test code:

// Internal functions of test components
it("In component method handleSearch Can be called normally", () => {
  const wrapper = wrappedMount()

  const instance = wrapper.instance()
  const spyFunction = jest.spyOn(instance, "handleSearch")
  instance.handleSearch()
  expect(spyFunction).toHaveBeenCalled() // handleSearch was called once
  spyFunction.mockRestore()
})

Execute npm run test:

Error: Cannot spy the handleSearch property because it is not a function; undefined given instead!

I have no choice but to search for answers. First of all stackoverflow The following scheme is obtained:

The general meaning is to wrap the component with shallowWithIntl(), and then the wrapped component needs to be wrapped with dive().

I immediately modified the code and ran npm run test again. The result is still the same.

No way, continue the search, in enzyme's #365issue See the answer that seems very close:

After jest.spyOn(), the component is forced to update: wrapper.instance().forceUpdate() and wrapper.update().

Then modify the code and debug, which is still invalid.

I'm depressed...

Many schemes have been found in the middle, but they are useless.

At this time, I happened to see a unit test summary written by other BU leaders in the internal document, so I had the cheek to talk to the leaders. Sure enough, this move worked very well. A word reminded the Dreamer: your component is wrapped by connect, which is a high-level component. You need to do the find operation before taking the instance, so as to get the instance of the real component.

After thanking the boss, I immediately went to practice:

// Internal functions of test components
it("In component method handleSearch Can be called normally", () => {
  const wrapper = wrappedMount()

  const instance = wrapper.find("MarketRuleManage").instance()
  const spyFunction = jest.spyOn(instance, "handleSearch")
  instance.handleSearch()
  expect(spyFunction).toHaveBeenCalled() // handleSearch was called once
  spyFunction.mockRestore()
})

Eager npm run test:

Well, the test case passed smoothly. It smells good!

After writing this use case, I can't help but reflect: young man, the foundation is still not very good

We should write more and practice more!

Test component state

Less nonsense, let's look at the fifth test case: after the content of the product ID input box is changed, the productID value in the state will change

Add test code:

// Test component state
it("product ID After the contents of the input box are changed, state in productID Will change", () => {
  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)
})

In fact, this is to simulate the user's input behavior, then use simulate to listen to the change event of the input box, and finally judge whether the change of input can be synchronized to state.

This use case actually means a bit of BDD

We run npm run test:

Use case successfully passed ~

Test component props

Finally came to the last test case: the MarketRuleManage component should accept the specified props parameter

Add test code:

// Test component props
it("MarketRuleManage The component should receive the specified 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()
})

Execute npm run test:

At this point, all our test cases will be executed ~

The six use cases we executed can basically comprehensively cover the component unit testing of React. Of course, because we use dva here, it is inevitable to test the model. I'll put a big man's name here Unit test of DVA example user dashboard , which has been listed in more detail, I will not teach others.

Keywords: Javascript React unit testing

Added by robin01 on Fri, 17 Sep 2021 23:21:01 +0300