上篇文章咱們已經瞭解了前端單元測試的背景和基礎的jest
api,本篇文章我會先介紹一下Enzyme
,而後結合項目中的一個真實組件,來爲它編寫測試用例。html
Enzyme
上一篇中咱們其實已經簡單介紹了enzyme
,但這遠遠不夠,在本篇的組件測試用例編寫中,咱們有不少地方要用到它,所以這裏專門來講明一下。前端
Enzyme
是由Airbnb
開源的一個React
的JavaScript
測試工具,使React
組件的輸出更加容易。Enzyme
的API
和jQuery
操做DOM
同樣靈活易用,由於它使用的是cheerio
庫來解析虛擬DOM
,而cheerio
的目標則是作服務器端的jQuery
。Enzyme
兼容大多數斷言庫和測試框架,如chai
、mocha
、jasmine
等。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
來模擬瀏覽器環境。三種方法中,shallow
和mount
由於返回的是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 輸入框內容更改後,state
中productID
值會隨之變化
六、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:組件生命週期能夠被正常調用
使用spyOn
來mock
組件的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
:
嗯,測試用例順利經過,真香!
寫完這個用例,我不由反思:小夥子,基礎仍是不太行啊
仍是要多寫多實踐才行啊!
廢話少說,咱們來看第五條測試用例:產品 ID 輸入框內容更改後,state
中productID
值會隨之變化
添加測試代碼:
// 測試組件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
:
用例順利經過~
終於來到了最後一個測試用例: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 單元測試,裏面已經列舉的比較詳細了,我就不班門弄斧了。