天天看點

Web前端單元測試到底要怎麼寫?看這一篇就夠了

随着 Web 應用的複雜程度越來越高,很多公司越來越重視前端單元測試。我們看到的大多數教程都會講單元測試的重要性、一些有代表性的測試架構 api 怎麼使用,但在實際項目中單元測試要怎麼下手?測試用例應該包含哪些具體内容呢?

本文從一個真實的應用場景出發,從設計模式、代碼結構來分析單元測試應該包含哪些内容,具體測試用例怎麼寫,希望看到的童鞋都能有所收獲。

項目用到的技術架構

該項目采用 react 技術棧,用到的主要架構包括: react、 redux、 react-redux、 redux-actions、 reselect、 redux-saga、 seamless-immutable、 antd。

應用場景介紹

Web前端單元測試到底要怎麼寫?看這一篇就夠了

這個應用場景從 UI 層來講主要由兩個部分組成:

  • 工具欄,包含重新整理按鈕、關鍵字搜尋框
  • 表格展示,采用分頁的形式浏覽

看到這裡有的童鞋可能會說:切!這麼簡單的界面和業務邏輯,還是真實場景嗎,還需要寫神馬單元測試嗎?

别急,為了保證文章的閱讀體驗和長度适中,能講清楚問題的簡潔場景就是好場景不是嗎?慢慢往下看。

設計模式與結構分析

在這個場景設計開發中,我們嚴格遵守 redux 單向資料流 與 react-redux 的最佳實踐,并采用 redux-saga 來處理業務流, reselect 來處理狀态緩存,通過 fetch 來調用背景接口,與真實的項目沒有差異。

分層設計與代碼組織如下所示:

Web前端單元測試到底要怎麼寫?看這一篇就夠了

中間 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,我們主要測試兩個方面:

  1. 對于未知的 action.type ,是否能傳回目前狀态。
  2. 對于每個業務 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



};
           

那麼我們的測試也主要是兩個方面:

  1. 對于業務 selector ,是否傳回了正确的内容。
  2. 緩存功能是否正常。

測試代碼如下:

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 的童鞋也不要太在意代碼的具體寫法,看注釋應該能了解這個業務的具體步驟:

  1. 從對應的 state 裡取到調用 api 時需要的參數部分(搜尋關鍵字、分頁),這裡調用了剛才的 selector。
  2. 組合好參數并調用對應的 api 層。
  3. 如果正常傳回結果,則發送成功 action 通知 reducer 更新狀态。
  4. 如果錯誤傳回,則發送錯誤 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%,根據實際情況來定):

Web前端單元測試到底要怎麼寫?看這一篇就夠了

單元測試是 TDD 測試驅動開發的基礎。從以上整個過程可以看出,好的設計分層是很容易編寫測試用例的,單元測試不單單隻是為了保證代碼品質:他會逼着你思考代碼設計的合理性,拒絕面條代碼 :muscle:

借用 Clean Code 的結束語:

2005 年,在參加于丹佛舉行的靈活大會時,Elisabeth Hedrickson 遞給我一條類似 Lance Armstrong 熱銷的那種綠色腕帶。這條腕帶上面寫着“沉迷測試”(Test Obsessed)的字樣。我高興地戴上,并自豪地一直系着。自從 1999 年從 Kent Beck 那兒學到 TDD 以來,我的确迷上了測試驅動開發。

不過跟着就發生了些奇事。我發現自己無法取下腕帶。不僅是因為腕帶很緊,而且那也是條精神上的緊箍咒。那腕帶就是我職業道德的宣告,也是我承諾盡己所能寫出最好代碼的提示。取下它,仿佛就是違背了這些宣告和承諾似的。

是以它還在我的手腕上。在寫代碼時,我用餘光瞟見它。它一直提醒我,我做了寫出整潔代碼的承諾。

原文釋出時間為:2018-08-16

本文作者:deepfunc

本文來自雲栖社群合作夥伴“

前端大學

”,了解相關資訊可以關注“

”微信公衆号

繼續閱讀