本文主要對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>
<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)
})
it("get child component's specific class", () => {
// expect(sWrapper.find('.commentItem .text').length).toBe(1)
// expect(sWrapper.find('.commentItem .text').exists()).toBeTruthy()
// expect(sWrapper.find('.commentItem .text')).toHaveLength(1)
expect(mWrapper.find('.commentItem .text').length).toBe(1)
expect(sWrapper.find('.commentItem .text').length).toBe(1)
})
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(sWrapper.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
幫助我們校驗記憶函式是否真的能記憶;
收工
以上,把自己的理解都簡單的描述了一遍,當然肯定會有缺漏或者偏頗,望指正。
沒有完整的寫過前端專案單元測試的經歷,剛好由於專案需要便認真去學習了一遍。
其中艱辛,希望眾位不要再經歷了。