随着 Web 應用的複雜程度越來越高,很多公司越來越重視前端單元測試。我們看到的大多數教程都會講單元測試的重要性、一些有代表性的測試架構 api 怎麼使用,但在實際項目中單元測試要怎麼下手?測試用例應該包含哪些具體内容呢?
本文從一個真實的應用場景出發,從設計模式、代碼結構來分析單元測試應該包含哪些内容,具體測試用例怎麼寫,希望看到的童鞋都能有所收獲。
項目用到的技術架構
該項目采用 react 技術棧,用到的主要架構包括: react、 redux、 react-redux、 redux-actions、 reselect、 redux-saga、 seamless-immutable、 antd。
應用場景介紹

這個應用場景從 UI 層來講主要由兩個部分組成:
- 工具欄,包含重新整理按鈕、關鍵字搜尋框
- 表格展示,采用分頁的形式浏覽
看到這裡有的童鞋可能會說:切!這麼簡單的界面和業務邏輯,還是真實場景嗎,還需要寫神馬單元測試嗎?
别急,為了保證文章的閱讀體驗和長度适中,能講清楚問題的簡潔場景就是好場景不是嗎?慢慢往下看。
設計模式與結構分析
在這個場景設計開發中,我們嚴格遵守 redux 單向資料流 與 react-redux 的最佳實踐,并采用 redux-saga 來處理業務流, reselect 來處理狀态緩存,通過 fetch 來調用背景接口,與真實的項目沒有差異。
分層設計與代碼組織如下所示:
中間 store 中的内容都是 redux 相關的,看名稱應該都能知道意思了。
具體的代碼請看這裡:https://github.com/deepfunc/react-test-demo。
單元測試部分介紹
先講一下用到了哪些測試架構和工具,主要内容包括:
- jest ,測試架構
- enzyme ,專測 react ui 層
- sinon ,具有獨立的 fakes、spies、stubs、mocks 功能庫
- nock ,模拟 HTTP Server
如果有童鞋對上面這些使用和配置不熟的話,直接看官方文檔吧,比任何教程都寫的好。
接下來,我們就開始編寫具體的測試用例代碼了,下面會針對每個層面給出代碼片段和解析。那麼我們先從 actions 開始吧。
為使文章盡量簡短、清晰,下面的代碼片段不是每個檔案的完整内容,完整内容在這裡:https://github.com/deepfunc/react-test-demo。
actions
業務裡面我使用了 redux-actions 來産生 action,這裡用工具欄做示例,先看一段業務代碼:
import { createAction } from 'redux-actions';
import * as type from '../types/bizToolbar';
export const updateKeywords = createAction(type.BIZ_TOOLBAR_KEYWORDS_UPDATE);
// ...
對于 actions 測試,我們主要是驗證産生的 action 對象是否正确:
import * as type from '@/store/types/bizToolbar';
import * as actions from '@/store/actions/bizToolbar';
/* 測試 bizToolbar 相關 actions */
describe('bizToolbar actions', () => {
/* 測試更新搜尋關鍵字 */
test('should create an action for update keywords', () => {
// 建構目标 action
const keywords = 'some keywords';
const expectedAction = {
type: type.BIZ_TOOLBAR_KEYWORDS_UPDATE,
payload: keywords
};
// 斷言 redux-actions 産生的 action 是否正确
expect(actions.updateKeywords(keywords)).toEqual(expectedAction);
});
// ...
});
這個測試用例的邏輯很簡單,首先建構一個我們期望的結果,然後調用業務代碼,最後驗證業務代碼的運作結果與期望是否一緻。這就是寫測試用例的基本套路。
我們在寫測試用例時盡量保持用例的單一職責,不要覆寫太多不同的業務範圍。測試用例數量可以有很多個,但每個都不應該很複雜。
reducers
接着是 reducers,依然采用 redux-actions 的 handleActions 來編寫 reducer,這裡用表格的來做示例:
import { handleActions } from 'redux-actions';
import Immutable from 'seamless-immutable';
import * as type from '../types/bizTable';
/* 預設狀态 */
export const defaultState = Immutable({
loading: false,
pagination: {
current: 1,
pageSize: 15,
total: 0
},
data: []
});
export default handleActions(
{
// ...
/* 處理獲得資料成功 */
[type.BIZ_TABLE_GET_RES_SUCCESS]: (state, {payload}) => {
return state.merge(
{
loading: false,
pagination: {total: payload.total},
data: payload.items
},
{deep: true}
);
},
// ...
},
defaultState
);
這裡的狀态對象使用了 seamless-immutable。
對于 reducer,我們主要測試兩個方面:
- 對于未知的 action.type ,是否能傳回目前狀态。
- 對于每個業務 type ,是否都傳回了經過正确處理的狀态。
下面是針對以上兩點的測試代碼:
import * as type from '@/store/types/bizTable';
import reducer, { defaultState } from '@/store/reducers/bizTable';
/* 測試 bizTable reducer */
describe('bizTable reducer', () => {
/* 測試未指定 state 參數情況下傳回目前預設 state */
test('should return the default state', () => {
expect(reducer(undefined, {type: 'UNKNOWN'})).toEqual(defaultState);
});
// ...
/* 測試處理正常資料結果 */
test('should handle successful data response', () => {
/* 模拟傳回資料結果 */
const payload = {
items: [
{id: 1, code: '1'},
{id: 2, code: '2'}
],
total: 2
};
/* 期望傳回的狀态 */
const expectedState = defaultState
.setIn(['pagination', 'total'], payload.total)
.set('data', payload.items)
.set('loading', false);
expect(
reducer(defaultState, {
type: type.BIZ_TABLE_GET_RES_SUCCESS,
payload
})
).toEqual(expectedState);
});
// ...
});
這裡的測試用例邏輯也很簡單,依然是上面斷言期望結果的套路。下面是 selectors 的部分。
selectors
selector 的作用是擷取對應業務的狀态,這裡使用了 reselect 來做緩存,防止 state 未改變的情況下重新計算,先看一下表格的 selector 代碼:
import { createSelector } from 'reselect';
import * as defaultSettings from '@/utils/defaultSettingsUtil';
// ...
const getBizTableState = (state) => state.bizTable;
export const getBizTable = createSelector(getBizTableState, (bizTable) => {
return bizTable.merge({
pagination: defaultSettings.pagination
}, {deep: true});
});
這裡的分頁器部分參數在項目中是統一設定,是以 reselect 很好的完成了這個工作:如果業務狀态不變,直接傳回上次的緩存。分頁器預設設定如下:
export const pagination = {
size: 'small',
showTotal: (total, range) => `${range[0]}-${range[1]} / ${total}`,
pageSizeOptions: ['15', '25', '40', '60'],
showSizeChanger: true,
showQuickJumper: true
};
那麼我們的測試也主要是兩個方面:
- 對于業務 selector ,是否傳回了正确的内容。
- 緩存功能是否正常。
測試代碼如下:
import Immutable from 'seamless-immutable';
import { getBizTable } from '@/store/selectors';
import * as defaultSettingsUtil from '@/utils/defaultSettingsUtil';
/* 測試 bizTable selector */
describe('bizTable selector', () => {
let state;
beforeEach(() => {
state = createState();
/* 每個用例執行前重置緩存計算次數 */
getBizTable.resetRecomputations();
});
function createState() {
return Immutable({
bizTable: {
loading: false,
pagination: {
current: 1,
pageSize: 15,
total: 0
},
data: []
}
});
}
/* 測試傳回正确的 bizTable state */
test('should return bizTable state', () => {
/* 業務狀态 ok 的 */
expect(getBizTable(state)).toMatchObject(state.bizTable);
/* 分頁預設參數設定 ok 的 */
expect(getBizTable(state)).toMatchObject({
pagination: defaultSettingsUtil.pagination
});
});
/* 測試 selector 緩存是否有效 */
test('check memoization', () => {
getBizTable(state);
/* 第一次計算,緩存計算次數為 1 */
expect(getBizTable.recomputations()).toBe(1);
getBizTable(state);
/* 業務狀态不變的情況下,緩存計算次數應該還是 1 */
expect(getBizTable.recomputations()).toBe(1);
const newState = state.setIn(['bizTable', 'loading'], true);
getBizTable(newState);
/* 業務狀态改變了,緩存計算次數應該是 2 了 */
expect(getBizTable.recomputations()).toBe(2);
});
});
測試用例依然很簡單有木有?保持這個節奏就對了。下面來講下稍微有點複雜的地方,sagas 部分。
sagas
這裡我用了 redux-saga 處理業務流,這裡具體也就是異步調用 api 請求資料,處理成功結果和錯誤結果等。
可能有的童鞋覺得搞這麼複雜幹嘛,異步請求用個 redux-thunk 不就完事了嗎?别急,耐心看完你就明白了。
這裡有必要大概介紹下 redux-saga 的工作方式。saga 是一種 es6 的生成器函數 - Generator ,我們利用他來産生各種聲明式的 effects ,由 redux-saga 引擎來消化處理,推動業務進行。
這裡我們來看看擷取表格資料的業務代碼:
import { all, takeLatest, put, select, call } from 'redux-saga/effects';
import * as type from '../types/bizTable';
import * as actions from '../actions/bizTable';
import { getBizToolbar, getBizTable } from '../selectors';
import * as api from '@/services/bizApi';
// ...
export function* onGetBizTableData() {
/* 先擷取 api 調用需要的參數:關鍵字、分頁資訊等 */
const {keywords} = yield select(getBizToolbar);
const {pagination} = yield select(getBizTable);
const payload = {
keywords,
paging: {
skip: (pagination.current - 1) * pagination.pageSize, max: pagination.pageSize
}
};
try {
/* 調用 api */
const result = yield call(api.getBizTableData, payload);
/* 正常傳回 */
yield put(actions.putBizTableDataSuccessResult(result));
} catch (err) {
/* 錯誤傳回 */
yield put(actions.putBizTableDataFailResult());
}
}
不熟悉 redux-saga 的童鞋也不要太在意代碼的具體寫法,看注釋應該能了解這個業務的具體步驟:
- 從對應的 state 裡取到調用 api 時需要的參數部分(搜尋關鍵字、分頁),這裡調用了剛才的 selector。
- 組合好參數并調用對應的 api 層。
- 如果正常傳回結果,則發送成功 action 通知 reducer 更新狀态。
- 如果錯誤傳回,則發送錯誤 action 通知 reducer。
那麼具體的測試用例應該怎麼寫呢?我們都知道這種業務代碼涉及到了 api 或其他層的調用,如果要寫單元測試必須做一些 mock 之類來防止真正調用 api 層,下面我們來看一下 怎麼針對這個 saga 來寫測試用例:
import { put, select } from 'redux-saga/effects';
// ...
/* 測試擷取資料 */
test('request data, check success and fail', () => {
/* 目前的業務狀态 */
const state = {
bizToolbar: {
keywords: 'some keywords'
},
bizTable: {
pagination: {
current: 1,
pageSize: 15
}
}
};
const gen = cloneableGenerator(saga.onGetBizTableData)();
/* 1. 是否調用了正确的 selector 來獲得請求時要發送的參數 */
expect(gen.next().value).toEqual(select(getBizToolbar));
expect(gen.next(state.bizToolbar).value).toEqual(select(getBizTable));
/* 2. 是否調用了 api 層 */
const callEffect = gen.next(state.bizTable).value;
expect(callEffect['CALL'].fn).toBe(api.getBizTableData);
/* 調用 api 層參數是否傳遞正确 */
expect(callEffect['CALL'].args[0]).toEqual({
keywords: 'some keywords',
paging: {skip: 0, max: 15}
});
/* 3. 模拟正确傳回分支 */
const successBranch = gen.clone();
const successRes = {
items: [
{id: 1, code: '1'},
{id: 2, code: '2'}
],
total: 2
};
expect(successBranch.next(successRes).value).toEqual(
put(actions.putBizTableDataSuccessResult(successRes)));
expect(successBranch.next().done).toBe(true);
/* 4. 模拟錯誤傳回分支 */
const failBranch = gen.clone();
expect(failBranch.throw(new Error('模拟産生異常')).value).toEqual(
put(actions.putBizTableDataFailResult()));
expect(failBranch.next().done).toBe(true);
});
這個測試用例相比前面的複雜了一些,我們先來說下測試 saga 的原理。前面說過 saga 實際上是傳回各種聲明式的 effects ,然後由引擎來真正執行。是以我們測試的目的就是要看 effects 的産生是否符合預期。那麼 effect 到底是個神馬東西呢?其實就是字面量對象!
我們可以用在業務代碼同樣的方式來産生這些字面量對象,對于字面量對象的斷言就非常簡單了,并且沒有直接調用 api 層,就用不着做 mock 咯!這個測試用例的步驟就是利用生成器函數一步步的産生下一個 effect ,然後斷言比較。
從上面的注釋 3、4 可以看到, redux-saga 還提供了一些輔助函數來友善的處理分支斷點。
這也是我選擇 redux-saga 的原因:強大并且利于測試。
api 和 fetch 工具庫
接下來就是api 層相關的了。前面講過調用背景請求是用的 fetch ,我封裝了兩個方法來簡化調用和結果處理: getJSON() 、 postJSON() ,分别對應 GET 、POST 請求。先來看看 api 層代碼:
import { fetcher } from '@/utils/fetcher';
export function getBizTableData(payload) {
return fetcher.postJSON('/api/biz/get-table', payload);
}
業務代碼很簡單,那麼測試用例也很簡單:
import sinon from 'sinon';
import { fetcher } from '@/utils/fetcher';
import * as api from '@/services/bizApi';
/* 測試 bizApi */
describe('bizApi', () => {
let fetcherStub;
beforeAll(() => {
fetcherStub = sinon.stub(fetcher);
});
// ...
/* getBizTableData api 應該調用正确的 method 和傳遞正确的參數 */
test('getBizTableData api should call postJSON with right params of fetcher', () => {
/* 模拟參數 */
const payload = {a: 1, b: 2};
api.getBizTableData(payload);
/* 檢查是否調用了工具庫 */
expect(fetcherStub.postJSON.callCount).toBe(1);
/* 檢查調用參數是否正确 */
expect(fetcherStub.postJSON.lastCall.calledWith('/api/biz/get-table', payload)).toBe(true);
});
});
由于 api 層直接調用了工具庫,是以這裡用 sinon.stub() 來替換工具庫達到測試目的。
接着就是測試自己封裝的 fetch 工具庫了,這裡 fetch 我是用的 isomorphic-fetch ,是以選擇了 nock 來模拟 Server 進行測試,主要是測試正常通路傳回結果和模拟伺服器異常等,示例片段如下:
import nock from 'nock';
import { fetcher, FetchError } from '@/utils/fetcher';
/* 測試 fetcher */
describe('fetcher', () => {
afterEach(() => {
nock.cleanAll();
});
afterAll(() => {
nock.restore();
});
/* 測試 getJSON 獲得正常資料 */
test('should get success result', () => {
nock('http://some')
.get('/test')
.reply(200, {success: true, result: 'hello, world'});
return expect(fetcher.getJSON('http://some/test')).resolves.toMatch(/^hello.+$/);
});
// ...
/* 測試 getJSON 捕獲 server 大于 400 的異常狀态 */
test('should catch server status: 400+', (done) => {
const status = 500;
nock('http://some')
.get('/test')
.reply(status);
fetcher.getJSON('http://some/test').catch((error) => {
expect(error).toEqual(expect.any(FetchError));
expect(error).toHaveProperty('detail');
expect(error.detail.status).toBe(status);
done();
});
});
/* 測試 getJSON 傳遞正确的 headers 和 query strings */
test('check headers and query string of getJSON()', () => {
nock('http://some', {
reqheaders: {
'Accept': 'application/json',
'authorization': 'Basic Auth'
}
})
.get('/test')
.query({a: '123', b: 456})
.reply(200, {success: true, result: true});
const headers = new Headers();
headers.append('authorization', 'Basic Auth');
return expect(fetcher.getJSON(
'http://some/test', {a: '123', b: 456}, headers)).resolves.toBe(true);
});
// ...
});
基本也沒什麼複雜的,主要注意 fetch 是 promise 傳回, jest 的各種異步測試方案都能很好滿足。
剩下的部分就是跟 UI 相關的了。
容器元件
容器元件的主要目的是傳遞 state 和 actions,看下工具欄的容器元件代碼:
import { connect } from 'react-redux';
import { getBizToolbar } from '@/store/selectors';
import * as actions from '@/store/actions/bizToolbar';
import BizToolbar from '@/components/BizToolbar';
const mapStateToProps = (state) => ({
...getBizToolbar(state)
});
const mapDispatchToProps = {
reload: actions.reload,
updateKeywords: actions.updateKeywords
};
export default connect(mapStateToProps, mapDispatchToProps)(BizToolbar);
那麼測試用例的目的也是檢查這些,這裡使用了 redux-mock-store 來模拟 redux 的 store :
import React from 'react';
import { shallow } from 'enzyme';
import configureStore from 'redux-mock-store';
import BizToolbar from '@/containers/BizToolbar';
/* 測試容器元件 BizToolbar */
describe('BizToolbar container', () => {
const initialState = {
bizToolbar: {
keywords: 'some keywords'
}
};
const mockStore = configureStore();
let store;
let container;
beforeEach(() => {
store = mockStore(initialState);
container = shallow(<BizToolbar store={store}/>);
});
/* 測試 state 到 props 的映射是否正确 */
test('should pass state to props', () => {
const props = container.props();
expect(props).toHaveProperty('keywords', initialState.bizToolbar.keywords);
});
/* 測試 actions 到 props 的映射是否正确 */
test('should pass actions to props', () => {
const props = container.props();
expect(props).toHaveProperty('reload', expect.any(Function));
expect(props).toHaveProperty('updateKeywords', expect.any(Function));
});
});
很簡單有木有,是以也沒啥可說的了。
UI 元件
這裡以表格元件作為示例,我們将直接來看測試用例是怎麼寫。一般來說 UI 元件我們主要測試以下幾個方面:
- 是否渲染了正确的 DOM 結構
- 樣式是否正确
- 業務邏輯觸發是否正确
下面是測試用例代碼:
import React from 'react';
import { mount } from 'enzyme';
import sinon from 'sinon';
import { Table } from 'antd';
import * as defaultSettingsUtil from '@/utils/defaultSettingsUtil';
import BizTable from '@/components/BizTable';
/* 測試 UI 元件 BizTable */
describe('BizTable component', () => {
const defaultProps = {
loading: false,
pagination: Object.assign({}, {
current: 1,
pageSize: 15,
total: 2
}, defaultSettingsUtil.pagination),
data: [{id: 1}, {id: 2}],
getData: sinon.fake(),
updateParams: sinon.fake()
};
let defaultWrapper;
beforeEach(() => {
defaultWrapper = mount(<BizTable {...defaultProps}/>);
});
// ...
/* 測試是否渲染了正确的功能子元件 */
test('should render table and pagination', () => {
/* 是否渲染了 Table 元件 */
expect(defaultWrapper.find(Table).exists()).toBe(true);
/* 是否渲染了 分頁器 元件,樣式是否正确(mini) */
expect(defaultWrapper.find('.ant-table-pagination.mini').exists()).toBe(true);
});
/* 測試首次加載時資料清單為空是否發起加載資料請求 */
test('when componentDidMount and data is empty, should getData', () => {
sinon.spy(BizTable.prototype, 'componentDidMount');
const props = Object.assign({}, defaultProps, {
pagination: Object.assign({}, {
current: 1,
pageSize: 15,
total: 0
}, defaultSettingsUtil.pagination),
data: []
});
const wrapper = mount(<BizTable {...props}/>);
expect(BizTable.prototype.componentDidMount.calledOnce).toBe(true);
expect(props.getData.calledOnce).toBe(true);
BizTable.prototype.componentDidMount.restore();
});
/* 測試 table 翻頁後是否正确觸發 updateParams */
test('when change pagination of table, should updateParams', () => {
const table = defaultWrapper.find(Table);
table.props().onChange({current: 2, pageSize: 25});
expect(defaultProps.updateParams.lastCall.args[0])
.toEqual({paging: {current: 2, pageSize: 25}});
});
});
得益于設計分層的合理性,我們很容易利用構造 props 來達到測試目的,結合 enzyme 和 sinon ,測試用例依然保持簡單的節奏。
總結
以上就是這個場景完整的測試用例編寫思路和示例代碼,文中提及的思路方法也完全可以用在 Vue 、 Angular 項目上。完整的代碼内容在 這裡 (重要的事情多說幾遍,各位童鞋覺得好幫忙去給個 :star: 哈)。
最後我們可以利用覆寫率來看下用例的覆寫程度是否足夠(一般來說不用刻意追求 100%,根據實際情況來定):
單元測試是 TDD 測試驅動開發的基礎。從以上整個過程可以看出,好的設計分層是很容易編寫測試用例的,單元測試不單單隻是為了保證代碼品質:他會逼着你思考代碼設計的合理性,拒絕面條代碼 :muscle:
借用 Clean Code 的結束語:
2005 年,在參加于丹佛舉行的靈活大會時,Elisabeth Hedrickson 遞給我一條類似 Lance Armstrong 熱銷的那種綠色腕帶。這條腕帶上面寫着“沉迷測試”(Test Obsessed)的字樣。我高興地戴上,并自豪地一直系着。自從 1999 年從 Kent Beck 那兒學到 TDD 以來,我的确迷上了測試驅動開發。
不過跟着就發生了些奇事。我發現自己無法取下腕帶。不僅是因為腕帶很緊,而且那也是條精神上的緊箍咒。那腕帶就是我職業道德的宣告,也是我承諾盡己所能寫出最好代碼的提示。取下它,仿佛就是違背了這些宣告和承諾似的。
是以它還在我的手腕上。在寫代碼時,我用餘光瞟見它。它一直提醒我,我做了寫出整潔代碼的承諾。
原文釋出時間為:2018-08-16
本文作者:deepfunc
本文來自雲栖社群合作夥伴“
前端大學”,了解相關資訊可以關注“
”微信公衆号