菜单

Web 前端单元测试到底要怎么写?看那一篇就够了

2019年5月3日 - Bootstrap

Web 前端单元测试到底要怎么写?看那壹篇就够了

2018/08/16 · 基础才干 ·
单元测试

原稿出处: deepfunc   

趁着 Web
应用的复杂程度越来越高,多数供销合作社更是注重前者单元测试。大家见到的大大多科目都会讲单元测试的基本点、一些有代表性的测试框架
api
怎么接纳,但在实际上项目中单元测试要怎么出手?测试用例应该包涵怎么着具体内容呢?

本文从1个真正的选择场景出发,从设计格局、代码结构来分析单元测试应该包蕴怎么样内容,具体测试用例怎么写,希望看到的童鞋都能具备收获。

测试驱动开辟(TDD)已经是如数家珍的名词,既然是测试驱动,那么测试用例代码就要写在支付代码的前头。可是什么写测试用例?写多少测试用例才够?作者想大家在实际上的操作进程都会发出那样的疑问。

体系用到的手艺框架

该项目应用 react
本领栈,用到的注重框架包蕴:reactreduxreact-reduxredux-actionsreselectredux-sagaseamless-immutableantd

八月1日,笔者在场了thoughtworks组织的“结对编程和TDD
Openworkshop”活动,聆听了tw的盛名咨询专家仝(tong二)键的优良讲授,并在助教的引路下实际加入了二回TDD和结对编程的经过。活动中,仝键先生对到底写多少测试用例才够的难题,给出了上边贰个解说:

选择场景介绍

图片 1

其一利用场景从 UI 层来说至关心重视要由八个部分构成:

见状这里有的童鞋或许会说:切!这么轻松的分界面和专门的学业逻辑,依然忠实处境呢,还索要写神马单元测试吗?

别急,为了确定保证小说的读书体验和长短适中,能讲通晓难题的简单场景正是好现象不是吧?稳步往下看。

大家写单元测试,有2个至关心珍视要的来头是用来严防投机犯低等错误的。大家不能够把写落成代码的人作为咱们的敌人,一定要把整个情景都测到,防止止他们在中间故意留下各类潜伏的陷阱。测试写的再多大概也尚未艺术覆盖任何气象,所以若是能让和煦以为安全就能够。怎样才具让投机以为安全呢?那是尚未规范答案的,只可以是写多了测试之后逐年体会。

除此以外,写测试也要花时间的,比如compare本条方式的落到实处部分,大家只花了一两分钟就写完了,而那么些测试代码,我们花了最少半个多钟头,那样做值得吗?对于简易的事情逻辑来讲,当然是不值得的,终究我们还广大做事等着做,老板花钱是为了我们的出品代码,而不是测试代码。

再考虑壹种状态,笔者要创业,想了三个难点,做了三个网址,小编当然是想以最快的速度把它做成型让别人用。若是本人在一起不明了人们会不会欣赏的时候,先花大批量时间写测试,最终开掘没人用只好忍痛割爱,那几个测试岂不是白写了。

故而照旧地方那句话:单元测试是令你晋级自身对代码的信念的,只要您以为安全能够延续支付时就够了,不是更多越好。

设计格局与布局解析

在这几个境况设计开荒中,大家严厉遵从 redux 单向数据流 与 react-redux
的超级施行,并利用 redux-saga 来管理业务流,reselect
来管理状态缓存,通过 fetch 来调用后台接口,与忠实的品种未有差距。

分层设计与代码组织如下所示:
图片 2

中间 store 中的内容都以 redux 相关的,看名称应当都能清楚意思了。

现实的代码请看 这里

小编相信上面壹段解释对于本文中提出的难题大家都未曾怎么异议。不过这里我们不思索特殊情况,在实际操作中,是否有点子对单元测试这一干活拓展度量?来判别是或不是丰硕?

单元测试部分介绍

先讲一下用到了何等测试框架和工具,主要内容包含:

假设有童鞋对上面这几个使用和布置不熟的话,直接看官方文书档案吧,比别的学科都写的好。

接下去,我们就起来编制具体的测试用例代码了,上面会针对各样层面给出代码片段和剖析。那么大家先从
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); // …

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 对象是或不是正确:

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); }); //
… });

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);
    });
 
    // …
});

其一测试用例的逻辑很简短,首先构建1个大家愿意的结果,然后调用业务代码,最终验明正身工作代码的运营结果与企盼是还是不是同样。这就是写测试用例的着力套路。

我们在写测试用例时尽量保险用例的10足任务,不要覆盖太多不相同的业务范围。测试用例数量得以有繁多个,但每一种都不应有很复杂。

利用代码覆盖率来衡量单元测试是不是丰盛

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 );

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,大家根本测试两个地点:

  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);
}); // … });

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 的1对。

布满的代码覆盖率有下边三种:

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}); });

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
很好的到位了那些工作:假使事情景况不改变,直接回到上次的缓存。分页器暗许设置如下:

export const pagination = { size: ‘small’, showTotal: (total, range)
=> `${range[0]}-${range[1]} / ${total}`, pageSizeOptions:
[’15’, ’25’, ’40’, ’60’], showSizeChanger: true, showQuickJumper: true
};

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
};

那么大家的测试也首假若八个地点:

  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); /* 第二遍总括,缓存计算次数为 一 */
expect(getBizTable.recomputations()).toBe(1); getBizTable(state); /*
业务情状不改变的场地下,缓存总结次数应当照旧 1 */
expect(getBizTable.recomputations()).toBe(1); const newState =
state.setIn([‘bizTable’, ‘loading’], true); getBizTable(newState); /*
业务情形改换了,缓存总结次数应当是 贰 了 */
expect(getBizTable.recomputations()).toBe(2); }); });

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);
    });
});

测试用举个例子故很粗大略有木有?保持那些点子就对了。上边来说下多少有点复杂的地方,sa瓦斯部分。

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()); } }

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
的童鞋也毫不太在意代码的求实写法,看注释应该能驾驭这么些业务的具体步骤:

  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));
/* 二. 是或不是调用了 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} }); /* 三.
模拟准确重回分支 */ 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); /* 四. 模拟错误重回分支
*/ const failBranch = gen.clone(); expect(failBranch.throw(new
Error(‘模拟产生十二分’)).value).toEqual(
put(actions.putBizTableDataFailResult()));
expect(failBranch.next().done).toBe(true); });

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
,然后断言相比。

从地方的讲授 三、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); }

1
2
3
4
5
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); }); });

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
进行测试,重如果测试不荒谬访问归来结果和模仿服务器万分等,示例片段如下:

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); }); // … });

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 相关的了。

int foo(int a, int b)
{
    int nReturn = 0;
    if (a < 10)
    {// 分支一
        nReturn+= 1;
    }
    if (b < 10)
    {// 分支二
        nReturn+= 10;
    }
    return nReturn;
}

容器组件

容器组件的基本点目标是传递 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);

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 :

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)); }); });

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));
    });
});

很简短有木有,所以也没啥可说的了。

大家精心看看逻辑,nReturn的结果一共有4种大概,我们透过路径覆盖的方法设计出来的测试用例:

UI 组件

此地以表格组件作为示范,大家将一向来看测试用例是怎么写。一般的话 UI
组件大家任重(英文名:rèn zhòng)而道远测试以下多少个方面:

上面是测试用例代码:

JavaScript

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}}); }); });

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 来达到测试目标,结合
enzymesinon ,测试用举例故维持轻便的韵律。

用例 参数 返回值
Test Case 1 a=5, b=5 0
Test Case 2 a=15, b=5 1
Test Case 3 a=5, b=15 10
Test Case 1 a=15, b=15 11

总结

上述便是以此地方完整的测试用例编写思路和演示代码,文中聊到的思绪办法也完全可以用在
VueAngular 项目上。完整的代码内容在
这里
(首要的政工多说三回,各位童鞋感到好扶持去给个 哈)。

最后大家能够利用覆盖率来看下用例的遮盖程度是或不是充分(一般的话不要刻意追求
百分之百,依据实况来定):
图片 3

单元测试是 TDD
测试驱动开辟的功底。从上述所有进程能够看来,好的设计分层是很轻易编写测试用例的,单元测试不单单只是为着保障代码品质:他会逼着您思索代码设计的客观,拒绝面条代码

借用 Clean Code 的结束语:

二〇〇七 年,在加入于金奈进行的全速大会时,Elisabeth Hedrickson
递给本身一条看似 Lance Armstrong热销的那种金棕腕带。那条腕带地点写着“沉迷测试”(Test
Obsessed)的字样。小编热情洋溢地戴上,并自豪地平昔系着。自从 一九玖6 年从 KentBeck 那儿学到 TDD 以来,小编的确迷上了测试驱动开拓。

不过随着就生出了些奇事。我发掘自个儿不可能取下腕带。不仅是因为腕带很紧,而且那也是条精神上的束缚。那腕带正是本身专门的工作道德的发表,也是本人答应尽己所能写出最佳代码的提示。取下它,就像正是违反了那一个发表和承诺似的。

于是它还在自己的手段上。在写代码时,作者用余光瞟见它。它直接提示笔者,作者做了写出清新代码的应允。

1 赞 1 收藏
评论

图片 4

Perfect。可是事实上中的代码往往比地点的例子复杂,假设代码中有五个if-else,那么根据路径覆盖的主意,至少要求二5=三210个测试用例。那样差不多要疯掉了。

 

没供给追求代码覆盖率,真正要覆盖的是逻辑

简短追求代码结构上的覆盖率,轻巧导致产生多量无意义的测试用例大概无法掩盖注重作业逻辑。我们再看看上边表达的率先段话。

大家写单元测试,有贰个至关心器重要的原委是用来防护投机犯低档错误的。我们不能把写完毕代码的人看作大家的仇敌,一定要把方方面面气象都测到,以预防他们在中间故意留下种种潜伏的陷阱。测试写的再多恐怕也一贯不艺术覆盖全体意况,所以若是能让和谐深感安全就可以。如何才具让投机感到到安全呢?那是不曾规范答案的,只可以是写多了测试之后逐年体会。

怎么才算让自个儿感到安全?覆盖逻辑,而不是代码。站在使用者的角度思索,要求关注的是软件落成逻辑,而不是覆盖率。如上边包车型地铁例证:

public class UserBusiness
    {
        public string CreateUser(User user)
        {
            string result = "success";

            if (string.IsNullOrEmpty(user.Username))
            {
                result = "usename is null or empty";
            }
            else if (string.IsNullOrEmpty(user.Password))
            {
                result = "password is null or empty";
            }
            else if (user.Password != user.ConfirmPassword)
            {
                result = "password is not equal to confirmPassword";
            }
            else if (string.IsNullOrEmpty(user.Creator))
            {
                result = "creator is null or empty";
            }
            else if (user.CreateDate == new DateTime())
            {
                result = "createdate must be assigned value";
            }
            else if (string.IsNullOrEmpty(user.CreatorIP))
            {
                result = "creatorIP is null or empty";
            }

            if (result != "success")
            {
                return result;
            }

            user.Username = user.Username.Trim();
            user.Password = BitConverter.ToString(MD5.Create().ComputeHash(Encoding.UTF8.GetBytes(user.Password)));

            UserDataAccess dataAccess = new UserDataAccess();
            dataAccess.CreateUser(user);

            return result;
        }
    }

在写UserBusiness.CreateUser的测试用例的时候,大家定义了下边多少个单元测试用例:

[TestClass()]
    public class UserBusinessTest
    {
        private TestContext testContextInstance;

        /// <summary>
        ///Gets or sets the test context which provides
        ///information about and functionality for the current test run.
        ///</summary>
        public TestContext TestContext
        {
            get
            {
                return testContextInstance;
            }
            set
            {
                testContextInstance = value;
            }
        }

        [TestMethod()]
        public void Should_Username_Not_Null_Or_Empty()
        {
            UserBusiness target = new UserBusiness();
            User user = new User();
            string expected = "usename is null or empty";
            string actual = target.CreateUser(user);
            Assert.AreEqual(expected, actual);
        }

        [TestMethod()]
        public void Should_Password_Not_Null_Or_Empty()
        {
            UserBusiness target = new UserBusiness();
            User user = new User()
            {
                Username = "ethan.cai"
            };
            string expected = "password is null or empty";
            string actual = target.CreateUser(user);
            Assert.AreEqual(expected, actual);
        }

        [TestMethod()]
        public void Should_Password_Equal_To_ConfirmPassword()
        {
            UserBusiness target = new UserBusiness();
            User user = new User()
            {
                Username = "ethan.cai",
                Password = "a121ww123",
                ConfirmPassword = "a121ww1231"
            };
            string expected = "password is not equal to confirmPassword";
            string actual = target.CreateUser(user);
            Assert.AreEqual(expected, actual);
        }

        [TestMethod()]
        public void Should_Creator_Not_Null_Or_Empty()
        {
            UserBusiness target = new UserBusiness();
            User user = new User()
            {
                Username = "ethan.cai",
                Password = "a121ww123",
                ConfirmPassword = "a121ww1231"
            };
            string expected = "password is not equal to confirmPassword";
            string actual = target.CreateUser(user);
            Assert.AreEqual(expected, actual);
        }

        [TestMethod()]
        public void Should_CreateDate_Assigned_Value()
        {
            UserBusiness target = new UserBusiness();
            User user = new User()
            {
                Username = "ethan.cai",
                Password = "a121ww123",
                ConfirmPassword = "a121ww123",
                Creator = "ethan.cai"
            };
            string expected = "createdate must be assigned value";
            string actual = target.CreateUser(user);
            Assert.AreEqual(expected, actual);
        }

        [TestMethod()]
        public void Should_CreatorIP_Not_Null_Or_Empty()
        {
            UserBusiness target = new UserBusiness();
            User user = new User()
            {
                Username = "ethan.cai",
                Password = "a121ww123",
                ConfirmPassword = "a121ww123",
                Creator = "ethan.cai",
                CreateDate = DateTime.Now
            };
            string expected = "creatorIP is null or empty";
            string actual = target.CreateUser(user);
            Assert.AreEqual(expected, actual);
        }

        [TestMethod()]
        public void Should_Trim_Username()
        {
            UserBusiness target = new UserBusiness();
            User user = new User()
            {
                Username = "ethan.cai  ",
                Password = "a121ww123",
                ConfirmPassword = "a121ww123",
                Creator = "ethan.cai",
                CreateDate = DateTime.Now,
                CreatorIP = "127.0.0.1"
            };
            string expected = "ethan.cai";
            target.CreateUser(user);
            Assert.AreEqual(expected, user.Username);
        }

        [TestMethod()]
        public void Should_Save_MD5_Hash_Password()
        {
            UserBusiness target = new UserBusiness();
            User user = new User()
            {
                Username = "ethan.cai  ",
                Password = "a121ww123",
                ConfirmPassword = "a121ww123",
                Creator = "ethan.cai",
                CreateDate = DateTime.Now,
                CreatorIP = "127.0.0.1"
            };

            string actual = target.CreateUser(user);
            Assert.IsTrue("success" == actual 
                && user.Password == BitConverter.ToString(MD5.Create().ComputeHash(Encoding.UTF8.GetBytes("a121ww123"))));
        }

        [TestMethod()]
        public void Should_Create_User_Successfully_When_User_Is_OK()
        {
            UserBusiness target = new UserBusiness();
            User user = new User()
            {
                Username = "ethan.cai  ",
                Password = "a121ww123",
                ConfirmPassword = "a121ww123",
                Creator = "ethan.cai",
                CreateDate = DateTime.Now,
                CreatorIP = "127.0.0.1"
            };
            string expected = "success";
            string actual = target.CreateUser(user);
            Assert.IsTrue(expected == actual);
        } 
    }

 

图片 5

只要仅从代码覆盖率的角度来看,单元测试Should_Trim_Username、Should_Save_MD5_Hash_Password不会大增覆盖率,就像从未供给,可是从逻辑上看,创造的账户的Username头尾无法蕴涵空白字符,密码也无法公开存款和储蓄,显著那多个用例是拾贰分有不可或缺的。

 

单元测试写多少才够?那几个题材并未有规定的答案,但规则是令你和谐感到安全。代码覆盖率高不能够担保卫安全全,真正的安全供给用测试用例覆盖逻辑。

 

事例代码:http://pan.baidu.com/s/1bneSQkf

参照小说:

相关文章

发表评论

电子邮件地址不会被公开。 必填项已用*标注

网站地图xml地图