本文主要对react全家桶应用的单元测试提供一点思路。
开工须知
Jest
Jest
是 Facebook 发布的一个开源的、基于Jasmine
框架的JavaScript
单元测试工具。提供了包括内置的测试环境DOM API
支持、断言库、Mock
库等,还包含了Spapshot Testing
、Instant Feedback
等特性。
Enzyme
Airbnb
开源的React
测试类库Enzyme
提供了一套简洁强大的API
,并通过jQuery
风格的方式进行DOM
处理,开发体验十分友好。不仅在开源社区有超高人气,同时也获得了React
官方的推荐。
redux-saga-test-plan
redux-saga-test-plan
运行在jest
环境下,模拟generator
函数,使用mock
数据进行测试,是对redux-saga
比较友好的一种测试方案。
开工准备
添加依赖
yarn add jest enzyme enzyme-adapter-react-16 enzyme-to-json redux-saga-test-plan@beta --dev
说明:
- 默认已经搭建好可用于
react
测试的环境 - 由于项目中使用
react
版本是在16以上,故需要安装enzyme
针对该版本的适配器enzyme-adapter-react-16
-
enzyme-to-json
用来序列化快照 - 请注意,大坑(尴尬的自问自答)。文档未提及对
redux-saga1.0.0-beta.0
的支持情况,所以如果按文档提示去安装则在测试时会有run
异常,我们在issue中发现解决方案。
配置
在package.json
中新增脚本命令
"scripts": {
...
"test": "jest"
}
然后再去对jest
进行配置,以下是两种配置方案:
- 直接在
package.json
新增jest
属性进行配置
"jest": {
"setupFiles": [
"./jestsetup.js"
],
"moduleFileExtensions": [
"js",
"jsx"
],
"snapshotSerializers": [
"enzyme-to-json/serializer"
],
"modulePaths": [
"<rootDir>/src"
],
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|css|less|scss)$": "identity-obj-proxy"
},
"testPathIgnorePatterns": [
'/node_modules/',
"helpers/test.js"
],
"collectCoverage": false
}
- 根目录下新建
xxx.js
文件,在脚本命令中添加--config xxx.js
,告知jest
去该文件读取配置信息。
module.exports = {
... // 同上
}
说明:
-
setupFiles
:在每个测试文件运行前,Jest
会先运行这里的配置文件来初始化指定的测试环境 -
moduleFileExtensions
:支持的文件类型 -
snapshotSerializers
: 序列化快照 -
testPathIgnorePatterns
:正则匹配要忽略的测试文件 -
moduleNameMapper
:代表需要被Mock
的文件类型,否则在运行测试脚本的时候常会报错:.css
或.png
等不存在 (如上需要添加identity-obj-proxy
开发依赖) -
collectCoverage
:是否生成测试覆盖报告,也可以在脚本命令后添加--coverage
以上仅列举了部分常用配置,更多详见官方文档。
开工大吉
对React
应用全家桶的测试主要可分为三大块。
组件测试
// Tab.js
import React from 'react'
import PropTypes from 'prop-types'
import TabCell from './TabCell'
import styles from './index.css'
const Tab = ({ type, activeTab, likes_count: liked, goings_count: going, past_count: past, handleTabClick }) => {
return (<div className={styles.tab}>
{type === 'user'
? <div>
![](https://user-gold-cdn.xitu.io/2018/11/1/166cf21e3bffb7d7?w=1484&h=1442&f=png&s=287585)
<TabCell type='liked' text={`${liked} Likes`} isActived={activeTab === 'liked'} handleTabClick={handleTabClick} />
<TabCell type='going' text={`${going} Going`} isActived={activeTab === 'going'} handleTabClick={handleTabClick} />
<TabCell type='past' text={`${past} Past`} isActived={activeTab === 'past'} handleTabClick={handleTabClick} />
</div>
: <div>
<TabCell type='details' text='Details' isActived={activeTab === 'details'} handleTabClick={handleTabClick} />
<TabCell type='participant' text='Participant' isActived={activeTab === 'participant'} handleTabClick={handleTabClick} />
<TabCell type='comment' text='Comment' isActived={activeTab === 'comment'} handleTabClick={handleTabClick} />
</div>
}
</div>)
}
Tab.propTypes = {
type: PropTypes.string,
activeTab: PropTypes.string,
likes_count: PropTypes.number,
goings_count: PropTypes.number,
past_count: PropTypes.number,
handleTabClick: PropTypes.func
}
export default Tab
// Tab.test.js
import React from 'react'
import { shallow, mount } from 'enzyme'
import renderer from 'react-test-renderer'
import Tab from 'components/Common/Tab'
import TabCell from 'components/Common/Tab/TabCell'
const setup = () => {
// 模拟props
const props = {
type: 'activity',
activeTab: 'participant',
handleTabClick: jest.fn()
}
const sWrapper = shallow(<Tab {...props} />)
const mWrapper = mount(<Tab {...props} />)
return {
props,
sWrapper,
mWrapper
}
}
describe('Tab components', () => {
const { sWrapper, mWrapper, props } = setup()
it("get child component TabCell's length", () => {
expect(sWrapper.find(TabCell).length).toBe(3)
expect(mWrapper.find(TabCell).length).toBe(3)
})
test('get specific class', () => {
expect(sWrapper.find('.active').exists())
expect(mWrapper.find('.active').exists())
})
it("get child component's specific class", () => {
expect(mWrapper.find('.commentItem .text').length).toBe(1)
expect(sWrapper.find('.commentItem .text').length).toBe(1)
})
test('shallowWrapper function to be called', () => {
sWrapper.find('.active .text').simulate('click')
expect(props.handleTabClick).toBeCalled()
})
test('mountWrapper function to be called', () => {
mWrapper.find('.active .text').simulate('click')
expect(props.handleTabClick).toBeCalled()
})
it('set props', () => {
expect(mWrapper.find('.participantItem.active')).toHaveLength(1)
mWrapper.setProps({activeTab: 'details'})
expect(mWrapper.find('.detailsItem.active')).toHaveLength(1)
})
// Snapshot
it('Snapshot', () => {
const tree = renderer.create(<Tab {...props} />).toJSON()
expect(tree).toMatchSnapshot()
})
})
说明:
-
test
方法是it
的一个别名,可以根据个人习惯选用; - 执行脚本可以发现
shallow
与mount
的些些区别:
-
shallow
只渲染当前组件,只能对当前组件做断言,所以expect(sWrapper.find('.active').exists())
正常而expect(mWrapper.find('.commentItem .text').length).toBe(1)
异常; -
mount
会渲染当前组件以及所有子组件,故而可以扩展到对其自组件做断言; -
enzyme
还提供另外一种渲染方式render
,与shallow
及mount
渲染出react
树不同,它的渲染结果是html
的dom
树,也因此它的耗时也较长;
-
-
jest
因Snapshot Testing
特性而备受关注,它将逐行比对你上一次建的快照,这可以很好的防止无意间修改组件的操作。
当然,你还可以在enzyme
的API Reference找到更多灵活的测试方案。
saga测试
// login.js部分代码
export function * login ({ payload: { params } }) {
yield put(startSubmit('login'))
let loginRes
try {
loginRes = yield call(fetch, {
ssl: false,
method: 'POST',
version: 'v1',
resource: 'auth/token',
payload: JSON.stringify({
...params
})
})
const {
token,
user: currentUser
} = loginRes
yield call(setToken, token)
yield put(stopSubmit('login'))
yield put(reset('login'))
yield put(loginSucceeded({ token, user: currentUser }))
const previousUserId = yield call(getUser)
if (previousUserId && previousUserId !== currentUser.id) {
yield put(reduxReset())
}
yield call(setUser, currentUser.id)
if (history.location.pathname === '/login') {
history.push('/home')
}
return currentUser
} catch (e) {
if (e.message === 'error') {
yield put(stopSubmit('login', {
username: [{
code: 'invalid'
}]
}))
} else {
if (e instanceof NotFound) {
console.log('notFound')
yield put(stopSubmit('login', {
username: [{
code: 'invalid'
}]
}))
} else if (e instanceof Forbidden) {
yield put(stopSubmit('login', {
password: [{
code: 'authorize'
}]
}))
} else if (e instanceof InternalServerError) {
yield put(stopSubmit('login', {
password: [{
code: 'server'
}]
}))
} else {
if (e.handler) {
yield call(e.handler)
}
console.log(e)
yield put(stopSubmit('login'))
}
}
}
}
// login.test.js
import {expectSaga} from 'redux-saga-test-plan'
import * as matchers from 'redux-saga-test-plan/matchers'
import { throwError } from 'redux-saga-test-plan/providers'
import {loginSucceeded, login} from '../login'
import fetch from 'helpers/fetch'
import {
startSubmit,
stopSubmit,
reset
} from 'redux-form'
import {
setToken,
getUser,
setUser
} from 'services/authorize'
const params = {
username: 'yy',
password: '123456'
}
it('login maybe works', () => {
const fakeResult = {
'token': 'd19911bda14cb0f36b82c9c6f6835c8c',
'user': {
'id': 53,
'username': 'yy',
'email': 'yan.yang@shopee.com',
'avatar': 'https://coding.net/static/fruit_avatar/Fruit-19.png'
}
}
return expectSaga(login, { payload: { params } })
.put(startSubmit('login'))
.provide([
[matchers.call.fn(fetch), fakeResult],
[matchers.call.fn(setToken), fakeResult.token],
[matchers.call.fn(getUser), 53],
[matchers.call.fn(setUser), 53]
])
.put(stopSubmit('login'))
.put(reset('login'))
.put(loginSucceeded({
token: fakeResult.token,
user: fakeResult.user
}))
.returns({...fakeResult.user})
.run()
})
it('catch an error', () => {
const error = new Error('error')
return expectSaga(login, { payload: { params } })
.put(startSubmit('login'))
.provide([
[matchers.call.fn(fetch), throwError(error)]
])
.put(stopSubmit('login', {
username: [{
code: 'invalid'
}]
}))
.run()
})
说明:
- 对照
saga
代码,梳理脚本逻辑(可以只编写对核心逻辑的断言); -
expectSaga
简化了测试,为我们提供了如redux-saga
风格般的API
。其中provide
极大的解放了我们mock
异步数据的烦恼;- 当然,在
provide
中除了使用matchers
,也可以直接使用redux-saga/effects
中的方法,不过注意如果直接使用effects
中的call
等方法将会执行该方法实体,而使用matchers
则不会。详见Static Providers;
- 当然,在
-
throwError
将模拟抛错,进入到catch
中;
selector测试
// activity.js
import { createSelector } from 'reselect'
export const inSearchSelector = state => state.activityReducer.inSearch
export const channelsSelector = state => state.activityReducer.channels
export const channelsMapSelector = createSelector(
[channelsSelector],
(channels) => {
const channelMap = {}
channels.forEach(channel => {
channelMap[channel.id] = channel
})
return channelMap
}
)
// activity.test.js
import {
inSearchSelector,
channelsSelector,
channelsMapSelector
} from '../activity'
describe('activity selectors', () => {
let channels
describe('test simple selectors', () => {
let state
beforeEach(() => {
channels = [{
id: 1,
name: '1'
}, {
id: 2,
name: '2'
}]
state = {
activityReducer: {
inSearch: false,
channels
}
}
})
describe('test inSearchSelector', () => {
it('it should return search state from the state', () => {
expect(inSearchSelector(state)).toEqual(state.activityReducer.inSearch)
})
})
describe('test channelsSelector', () => {
it('it should return channels from the state', () => {
expect(channelsSelector(state)).toEqual(state.activityReducer.channels)
})
})
})
describe('test complex selectors', () => {
let state
const res = {
1: {
id: 1,
name: '1'
},
2: {
id: 2,
name: '2'
}
}
const reducer = channels => {
return {
activityReducer: {channels}
}
}
beforeEach(() => {
state = reducer(channels)
})
describe('test channelsMapSelector', () => {
it('it should return like res', () => {
expect(channelsMapSelector(state)).toEqual(res)
expect(channelsMapSelector.resultFunc(channels))
})
it('recoputations count correctly', () => {
channelsMapSelector(state)
expect(channelsMapSelector.recomputations()).toBe(1)
state = reducer([{
id: 3,
name: '3'
}])
channelsMapSelector(state)
expect(channelsMapSelector.recomputations()).toBe(2)
})
})
})
})
说明:
-
channelsMapSelector
可以称之为记忆函数,只有当其依赖值发生改变时才会触发更新,当然也可能会发生意外,而inSearchSelector
与channelsSelector
仅仅是两个普通的非记忆selector
函数,并没有变换他们select
的数据; - 如果我们的
selector
中聚合了比较多其他的selector
,resultFunc
可以帮助我们mock数据,不需要再从state
中解藕出对应数据; -
recomputations
帮助我们校验记忆函数是否真的能记忆;
收工
以上,把自己的理解都简单的描述了一遍,当然肯定会有缺漏或者偏颇,望指正。
没有完整的写过前端项目单元测试的经历,刚好由于项目需要便认真去学习了一遍。
其中艰辛,希望众位不要再经历了。