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

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

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

項目用到的技術框架

該項目採用 react 技術棧,用到的主要框架包括:reactreduxreact-reduxredux-actionsreselectredux-sagaseamless-immutableantdjava

應用場景介紹

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

  • 工具欄,包含刷新按鈕、關鍵字搜索框
  • 表格展現,採用分頁的形式瀏覽

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

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

設計模式與結構分析

在這個場景設計開發中,咱們嚴格遵照 redux 單向數據流 與 react-redux 的最佳實踐,並採用 redux-saga 來處理業務流,reselect 來處理狀態緩存,經過 fetch 來調用後臺接口,與真實的項目沒有差別。github

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

中間 store 中的內容都是 redux 相關的,看名稱應該都能知道意思了。redux

具體的代碼請看 這裏設計模式

單元測試部分介紹

先講一下用到了哪些測試框架和工具,主要內容包括:

  • jest ,測試框架
  • enzyme ,專測 react ui 層
  • sinon ,具備獨立的 fakes、spies、stubs、mocks 功能庫
  • nock ,模擬 HTTP Server

若是有童鞋對上面這些使用和配置不熟的話,直接看官方文檔吧,比任何教程都寫的好。

接下來,咱們就開始編寫具體的測試用例代碼了,下面會針對每一個層面給出代碼片斷和解析。那麼咱們先從 actions 開始吧。

爲使文章儘可能簡短、清晰,下面的代碼片斷不是每一個文件的完整內容,完整內容在 這裏

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-actionshandleActions 來編寫 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 ,而後斷言比較。

從上面的註釋 三、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 來達到測試目的,結合 enzymesinon ,測試用例依然保持簡單的節奏。

總結

以上就是這個場景完整的測試用例編寫思路和示例代碼,文中說起的思路方法也徹底能夠用在 VueAngular 項目上。完整的代碼內容在 這裏 (重要的事情多說幾遍,各位童鞋以爲好幫忙去給個 :star: 哈)。

最後咱們能夠利用覆蓋率來看下用例的覆蓋程度是否足夠(通常來講不用刻意追求 100%,根據實際狀況來定):

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

借用 Clean Code 的結束語:

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

不過跟着就發生了些奇事。我發現本身沒法取下腕帶。不只是由於腕帶很緊,並且那也是條精神上的緊箍咒。那腕帶就是我職業道德的宣告,也是我承諾盡己所能寫出最好代碼的提示。取下它,彷彿就是違背了這些宣告和承諾似的。

因此它還在個人手腕上。在寫代碼時,我用餘光瞟見它。它一直提醒我,我作了寫出整潔代碼的承諾。

相關文章
相關標籤/搜索