共计 12260 个字符,预计需要花费 31 分钟才能阅读完成。
👨🌾 写在后面
上篇文章咱们曾经理解了前端单元测试的背景和根底的jest
api,本篇文章我会先介绍一下Enzyme
,而后联合我的项目中的一个实在组件,来为它编写测试用例。
👨🚀 Enzyme
上一篇中咱们其实曾经简略介绍了enzyme
,但这远远不够,在本篇的组件测试用例编写中,咱们有很多中央要用到它,因而这里专门来阐明一下。
Enzyme
是由 Airbnb
开源的一个 React
的JavaScript
测试工具,使 React
组件的输入更加容易。Enzyme
的 API
和jQuery
操作 DOM
一样灵便易用,因为它应用的是 cheerio
库来解析虚构 DOM
,而cheerio
的指标则是做服务器端的 jQuery
。Enzyme
兼容大多数断言库和测试框架,如 chai
、mocha
、jasmine
等。
🙋 对于装置和配置,上一大节曾经有过阐明,这里就不赘述了
罕用函数
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
来模仿浏览器环境。
三种办法中,shallow
和 mount
因为返回的是 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 输入框内容更改后,state
中 productID
值会随之变动
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:组件生命周期能够被失常调用
应用 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
:
嗯,测试用例顺利通过,真香!
写完这个用例,我不禁反思:小伙子,根底还是不太行啊
还是要多写多实际才行啊!
测试组件 state
废话少说,咱们来看第五条测试用例:产品 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
:
用例顺利通过~
测试组件 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 单元测试,外面曾经列举的比拟具体了,我就不班门弄斧了。