本文主要對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
幫助我們校驗記憶函式是否真的能記憶;
收工
以上,把自己的理解都簡單的描述了一遍,當然肯定會有缺漏或者偏頗,望指正。
沒有完整的寫過前端專案單元測試的經歷,剛好由於專案需要便認真去學習了一遍。
其中艱辛,希望眾位不要再經歷了。