那些年錯過的React組件單元測試(下)

👨‍🌾 寫在前面

上篇文章咱們已經瞭解了前端單元測試的背景和基礎的jestapi,本篇文章我會先介紹一下Enzyme,而後結合項目中的一個真實組件,來爲它編寫測試用例。html

👨‍🚀 Enzyme

上一篇中咱們其實已經簡單介紹了enzyme,但這遠遠不夠,在本篇的組件測試用例編寫中,咱們有不少地方要用到它,所以這裏專門來講明一下。前端

Enzyme是由Airbnb開源的一個ReactJavaScript測試工具,使React組件的輸出更加容易。EnzymeAPIjQuery操做DOM同樣靈活易用,由於它使用的是cheerio庫來解析虛擬DOM,而cheerio的目標則是作服務器端的jQueryEnzyme兼容大多數斷言庫和測試框架,如chaimochajasmine等。node

🙋 關於安裝和配置,上一小節已經有過說明,這裏就不贅述了

經常使用函數

enzyme中有幾個比較核心的函數,以下:react

  • 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 支持三種方式的渲染:git

  • shallow:淺渲染,是對官方的Shallow Renderer的封裝。將組件渲染成虛擬DOM對象,只會渲染第一層,子組件將不會被渲染出來,於是效率很是高。不須要 DOM 環境, 並可使用jQuery的方式訪問組件的信息;
  • render:靜態渲染,它將React組件渲染成靜態的HTML字符串,而後使用Cheerio這個庫解析這段字符串,並返回一個Cheerio的實例對象,能夠用來分析組件的html結構;
  • mount:徹底渲染,它將組件渲染加載成一個真實的DOM節點,用來測試DOM API的交互和組件的生命週期,用到了jsdom來模擬瀏覽器環境。

三種方法中,shallowmount由於返回的是DOM對象,能夠用simulate進行交互模擬,而render方法不能夠。通常shallow方法就能夠知足需求,若是須要對子組件進行判斷,須要使用render,若是須要測試組件的生命週期,須要使用mount方法。github

渲染方式部分參考的 這篇文章

🐶 「踩坑之路」開啓

組件代碼

首先,來看下咱們須要對其進行測試的組件部分的代碼:npm

⚠️ 由於牽扯到內部代碼,因此不少地方都打碼了。重在演示針對不一樣類型的測試用例的編寫
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包裹的高階組件,頁面展現以下:
redux

咱們要添加的測試用例以下:api

一、頁面可以正常渲染瀏覽器

二、DOM測試:標題應該爲XX錄入系統

三、組件生命週期能夠被正常調用

四、組件內方法handleSearch(即「查詢」按鈕上綁定的事件)能夠被正常調用

五、產品 ID 輸入框內容更改後,stateproductID值會隨之變化

六、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 單元測試,裏面已經列舉的比較詳細了,我就不班門弄斧了。

相關文章
相關標籤/搜索