本文主要對(duì)react全家桶應(yīng)用的單元測(cè)試提供一點(diǎn)思路。
開工須知
Jest
Jest是 Facebook 發(fā)布的一個(gè)開源的、基于Jasmine框架的JavaScript單元測(cè)試工具。提供了包括內(nèi)置的測(cè)試環(huán)境DOM API支持、斷言庫、Mock庫等,還包含了Spapshot Testing、Instant Feedback等特性。
Enzyme
Airbnb開源的React測(cè)試類庫Enzyme提供了一套簡(jiǎn)潔強(qiáng)大的API,并通過jQuery風(fēng)格的方式進(jìn)行DOM處理,開發(fā)體驗(yàn)十分友好。不僅在開源社區(qū)有超高人氣,同時(shí)也獲得了React官方的推薦。
redux-saga-test-plan
redux-saga-test-plan運(yùn)行在jest環(huán)境下,模擬generator函數(shù),使用mock數(shù)據(jù)進(jìn)行測(cè)試,是對(duì)redux-saga比較友好的一種測(cè)試方案。
開工準(zhǔn)備
添加依賴
yarn add jest enzyme enzyme-adapter-react-16 enzyme-to-json redux-saga-test-plan@beta --dev
說明:
- 默認(rèn)已經(jīng)搭建好可用于
react測(cè)試的環(huán)境 - 由于項(xiàng)目中使用
react版本是在16以上,故需要安裝enzyme針對(duì)該版本的適配器enzyme-adapter-react-16 -
enzyme-to-json用來序列化快照 - 請(qǐng)注意,大坑(尷尬的自問自答)。文檔未提及對(duì)
redux-saga1.0.0-beta.0的支持情況,所以如果按文檔提示去安裝則在測(cè)試時(shí)會(huì)有run異常,我們?cè)?a target="_blank" rel="nofollow">issue中發(fā)現(xiàn)解決方案。
配置
在package.json中新增腳本命令
"scripts": {
...
"test": "jest"
}
然后再去對(duì)jest進(jìn)行配置,以下是兩種配置方案:
- 直接在
package.json新增jest屬性進(jìn)行配置
"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:在每個(gè)測(cè)試文件運(yùn)行前,Jest會(huì)先運(yùn)行這里的配置文件來初始化指定的測(cè)試環(huán)境 -
moduleFileExtensions:支持的文件類型 -
snapshotSerializers: 序列化快照 -
testPathIgnorePatterns:正則匹配要忽略的測(cè)試文件 -
moduleNameMapper:代表需要被Mock的文件類型,否則在運(yùn)行測(cè)試腳本的時(shí)候常會(huì)報(bào)錯(cuò):.css或.png等不存在 (如上需要添加identity-obj-proxy開發(fā)依賴) -
collectCoverage:是否生成測(cè)試覆蓋報(bào)告,也可以在腳本命令后添加--coverage
以上僅列舉了部分常用配置,更多詳見官方文檔。
開工大吉
對(duì)React應(yīng)用全家桶的測(cè)試主要可分為三大塊。
組件測(cè)試
// 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>

<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的一個(gè)別名,可以根據(jù)個(gè)人習(xí)慣選用; - 執(zhí)行腳本可以發(fā)現(xiàn)
shallow與mount的些些區(qū)別:
執(zhí)行腳本-
shallow只渲染當(dāng)前組件,只能對(duì)當(dāng)前組件做斷言,所以expect(sWrapper.find('.active').exists())正常而expect(mWrapper.find('.commentItem .text').length).toBe(1)異常; -
mount會(huì)渲染當(dāng)前組件以及所有子組件,故而可以擴(kuò)展到對(duì)其自組件做斷言; -
enzyme還提供另外一種渲染方式render,與shallow及mount渲染出react樹不同,它的渲染結(jié)果是html的dom樹,也因此它的耗時(shí)也較長(zhǎng);
-
-
jest因Snapshot Testing特性而備受關(guān)注,它將逐行比對(duì)你上一次建的快照,這可以很好的防止無意間修改組件的操作。
Snapshot Testing
當(dāng)然,你還可以在enzyme的API Reference找到更多靈活的測(cè)試方案。
saga測(cè)試
// 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()
})
說明:
- 對(duì)照
saga代碼,梳理腳本邏輯(可以只編寫對(duì)核心邏輯的斷言); -
expectSaga簡(jiǎn)化了測(cè)試,為我們提供了如redux-saga風(fēng)格般的API。其中provide極大的解放了我們mock異步數(shù)據(jù)的煩惱;- 當(dāng)然,在
provide中除了使用matchers,也可以直接使用redux-saga/effects中的方法,不過注意如果直接使用effects中的call等方法將會(huì)執(zhí)行該方法實(shí)體,而使用matchers則不會(huì)。詳見Static Providers;
- 當(dāng)然,在
-
throwError將模擬拋錯(cuò),進(jìn)入到catch中;
selector測(cè)試
// 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可以稱之為記憶函數(shù),只有當(dāng)其依賴值發(fā)生改變時(shí)才會(huì)觸發(fā)更新,當(dāng)然也可能會(huì)發(fā)生意外,而inSearchSelector與channelsSelector僅僅是兩個(gè)普通的非記憶selector函數(shù),并沒有變換他們select的數(shù)據(jù); - 如果我們的
selector中聚合了比較多其他的selector,resultFunc可以幫助我們mock數(shù)據(jù),不需要再從state中解藕出對(duì)應(yīng)數(shù)據(jù); -
recomputations幫助我們校驗(yàn)記憶函數(shù)是否真的能記憶;
收工
以上,把自己的理解都簡(jiǎn)單的描述了一遍,當(dāng)然肯定會(huì)有缺漏或者偏頗,望指正。
沒有完整的寫過前端項(xiàng)目單元測(cè)試的經(jīng)歷,剛好由于項(xiàng)目需要便認(rèn)真去學(xué)習(xí)了一遍。
其中艱辛,希望眾位不要再經(jīng)歷了。