隨著 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
相關的,看名稱應該都能知道意思了。
具體的程式碼請看 這裡。
單元測試部分介紹
先講一下用到了哪些測試框架和工具,主要內容包括:
jest
,測試框架enzyme
,專測 react ui 層sinon
,具有獨立的 fakes、spies、stubs、mocks 功能庫nock
,模擬 HTTP Server
如果有童鞋對上面這些使用和配置不熟的話,直接看官方文件吧,比任何教程都寫的好。
接下來,我們就開始編寫具體的測試用例程式碼了,下面會針對每個層面給出程式碼片段和解析。那麼我們先從 actions
開始吧。
為使文章儘量簡短、清晰,下面的程式碼片段不是每個檔案的完整內容,完整內容在 這裡 。
actions
業務裡面我使用了 redux-actions
來產生 action
,這裡用工具欄做示例,先看一段業務程式碼:
1 2 3 4 5 6 |
import { createAction } from 'redux-actions'; import * as type from '../types/bizToolbar'; export const updateKeywords = createAction(type.BIZ_TOOLBAR_KEYWORDS_UPDATE); // ... |
對於 actions
測試,我們主要是驗證產生的 action
物件是否正確:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
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
,這裡用表格的來做示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
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 ,是否都返回了經過正確處理的狀態。
下面是針對以上兩點的測試程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
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 程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 |
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 很好的完成了這個工作:如果業務狀態不變,直接返回上次的快取。分頁器預設設定如下:
1 2 3 4 5 6 7 |
export const pagination = { size: 'small', showTotal: (total, range) => `${range[0]}-${range[1]} / ${total}`, pageSizeOptions: ['15', '25', '40', '60'], showSizeChanger: true, showQuickJumper: true }; |
那麼我們的測試也主要是兩個方面:
- 對於業務 selector ,是否返回了正確的內容。
- 快取功能是否正常。
測試程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 |
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
引擎來消化處理,推動業務進行。
這裡我們來看看獲取表格資料的業務程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
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 來寫測試用例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
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 層程式碼:
1 2 3 4 5 |
import { fetcher } from '@/utils/fetcher'; export function getBizTableData(payload) { return fetcher.postJSON('/api/biz/get-table', payload); } |
業務程式碼很簡單,那麼測試用例也很簡單:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
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 進行測試,主要是測試正常訪問返回結果和模擬伺服器異常等,示例片段如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 |
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,看下工具欄的容器元件程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
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(); }); /* 測試 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 結構
- 樣式是否正確
- 業務邏輯觸發是否正確
下面是測試用例程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
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
專案上。完整的程式碼內容在 這裡 (重要的事情多說幾遍,各位童鞋覺得好幫忙去給個 哈)。
最後我們可以利用覆蓋率來看下用例的覆蓋程度是否足夠(一般來說不用刻意追求 100%,根據實際情況來定):
單元測試是 TDD 測試驅動開發的基礎。從以上整個過程可以看出,好的設計分層是很容易編寫測試用例的,單元測試不單單只是為了保證程式碼質量:他會逼著你思考程式碼設計的合理性,拒絕麵條程式碼
借用 Clean Code 的結束語:
2005 年,在參加于丹佛舉行的敏捷大會時,Elisabeth Hedrickson 遞給我一條類似 Lance Armstrong 熱銷的那種綠色腕帶。這條腕帶上面寫著“沉迷測試”(Test Obsessed)的字樣。我高興地戴上,並自豪地一直系著。自從 1999 年從 Kent Beck 那兒學到 TDD 以來,我的確迷上了測試驅動開發。
不過跟著就發生了些奇事。我發現自己無法取下腕帶。不僅是因為腕帶很緊,而且那也是條精神上的緊箍咒。那腕帶就是我職業道德的宣告,也是我承諾盡己所能寫出最好程式碼的提示。取下它,彷彿就是違背了這些宣告和承諾似的。
所以它還在我的手腕上。在寫程式碼時,我用餘光瞟見它。它一直提醒我,我做了寫出整潔程式碼的承諾。