【Web技術】639- Web前端單元測試到底要怎麼寫?

作者:deepfunc

https://segmentfault.com/a/1190000015935519

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

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

項目用到的技術框架

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

應用場景介紹

這個應用場景從 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-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 ,然後斷言比較。

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

總結

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

最後我們可以利用覆蓋率來看下用例的覆蓋程度是否足夠(一般來說不用刻意追求 100%,根據實際情況來定):

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

借用 Clean Code 的結束語:

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

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

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

1. JavaScript 重溫系列(22篇全)

2. ECMAScript 重溫系列(10篇全)

3. JavaScript設計模式 重溫系列(9篇全)

4. 正則 / 框架 / 算法等 重溫系列(16篇全)

5. Webpack4 入門(上)|| Webpack4 入門(下)

6. MobX 入門(上) ||  MobX 入門(下)

7. 59篇原創系列彙總

回覆“加羣”與大佬們一起交流學習~

點擊“閱讀原文”查看70+篇原創文章

點這,與大家一起分享本文吧~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章