我的回答
先讲一下用到了哪些测试框架和工具,主要内容包括:
- jest ,测试框架
- enzyme ,专测 react ui 层
- sinon ,具有独立的 fakes、spies、stubs、mocks 功能库
- mock ,模拟 HTTP Server
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', () => {// 构建目标 actionconst 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,我们主要测试两个方面:
- 对于未知的 action.type ,是否能返回当前状态。
- 对于每个业务 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
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});});
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};
那么我们的测试也主要是两个方面:
- 对于业务 selector ,是否返回了正确的内容。
- 缓存功能是否正常。
测试代码如下:
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
这里我用了 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());}}
这个业务的具体步骤:
- 从对应的 state 里取到调用 api 时需要的参数部分(搜索关键字、分页),这里调用了刚才的 selector。
- 组合好参数并调用对应的 api 层。
- 如果正常返回结果,则发送成功 action 通知 reducer 更新状态。
- 如果错误返回,则发送错误 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 实际上是返回各种声明式的 effects ,然后由引擎来真正执行。所以我们测试的目的就是要看 effects 的产生是否符合预期。那么 effect 到底是个神马东西呢?其实就是字面量对象!
我们可以用在业务代码同样的方式来产生这些字面量对象,对于字面量对象的断言就非常简单了,并且没有直接调用 api 层,就用不着做 mock 咯!这个测试用例的步骤就是利用生成器函数一步步的产生下一个 effect ,然后断言比较。
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);});// ...});
参考回答
单元检测
在单元检测的时候,常用的方法论是:TDD和BDD.
TDD(Test-driven development): 其基本思路是通过测试推动开发的进行。从调用者的角度触发,尝试函数逻辑的各种可能性 ,进而辅助性增强代码质量。
BDD(Behavior-driven development): 其基本思路是通过预期行为逐步构建功能块。通过与客户讨论,对预期结果有初步的认知,尝试达到预期效果
目前前端测试框架有Mocha、jasmine、jest等,它们配合断言库来进行单元测试。断言库包括assert(nodejs自带的断言库)、chai等
前端为什么做单元测试
- 正确性:测试可以验证代码的正确性,在上线前做到心里有底
- 自动化:当然手工也可以测试,通过console可以打印出内部信息,但是这是一次性的事情,下次测试还需要从头来过,效率不能得到保证。通过编写测试用例,可以做到一次编写,多次运行
- 解释性:测试用例用于测试接口、模块的重要性,那么在测试用例中就会涉及如何使用这些API。其他开发人员如果要使用这些API,那阅读测试用例是一种很好地途径,有时比文档说明更清晰
- 驱动开发,指导设计:代码被测试的前提是代码本身的可测试性,那么要保证代码的可测试性,就需要在开发中注意API的设计,TDD将测试前移就是起到这么一个作用
- 保证重构:互联网行业产品迭代速度很快,迭代后必然存在代码重构的过程,那怎么才能保证重构后代码的质量呢?有测试用例做后盾,就可以大胆的进行重构
单元测试的库
QUnit、jasmine、mocha、jest、intern
普通测试
import { expect } from 'chai';import add from '../src/common/add';describe('加法函数测试', () => {it('应该返回两个数之和', () => {expect(add(4, 5)).to.be.equal(9);});});/*加法函数测试√ 应该返回两个数之和1 passing*/
异步函数测试
import { expect } from 'chai';import asyncFn from('../src/common/asyncFn');describe('异步函数测试', () => {it('async with done', async () => {const res = await asyncFn();expect(res).to.have.deep.property('status', 200);});});/*异步函数测试√ async with done (176ms)1 passing*/
