上篇文章咱们已经了解了前端单元测试的背景和基础的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 单元测试,里面已经列举的比较详细了,我就不班门弄斧了。