我的回答

先讲一下用到了哪些测试框架和工具,主要内容包括:

  • jest ,测试框架
  • enzyme ,专测 react ui 层
  • sinon ,具有独立的 fakes、spies、stubs、mocks 功能库
  • mock ,模拟 HTTP Server

actions

业务里面我使用了 redux-actions 来产生 action,这里用工具栏做示例,先看一段业务代码:

  1. import { createAction } from 'redux-actions';
  2. import * as type from '../types/bizToolbar';
  3. export const updateKeywords = createAction(type.BIZ_TOOLBAR_KEYWORDS_UPDATE);
  4. // ...

对于 actions 测试,我们主要是验证产生的 action 对象是否正确:

  1. import * as type from '@/store/types/bizToolbar';
  2. import * as actions from '@/store/actions/bizToolbar';
  3. /* 测试 bizToolbar 相关 actions */
  4. describe('bizToolbar actions', () => {
  5. /* 测试更新搜索关键字 */
  6. test('should create an action for update keywords', () => {
  7. // 构建目标 action
  8. const keywords = 'some keywords';
  9. const expectedAction = {
  10. type: type.BIZ_TOOLBAR_KEYWORDS_UPDATE,
  11. payload: keywords
  12. };
  13. // 断言 redux-actions 产生的 action 是否正确
  14. expect(actions.updateKeywords(keywords)).toEqual(expectedAction);
  15. });
  16. // ...
  17. });

这个测试用例的逻辑很简单,首先构建一个我们期望的结果,然后调用业务代码,最后验证业务代码的运行结果与期望是否一致。这就是写测试用例的基本套路。
我们在写测试用例时尽量保持用例的单一职责,不要覆盖太多不同的业务范围。测试用例数量可以有很多个,但每个都不应该很复杂。

reducers

接着是 reducers,依然采用 redux-actions 的 handleActions 来编写 reducer,这里用表格的来做示例:

  1. import { handleActions } from 'redux-actions';
  2. import Immutable from 'seamless-immutable';
  3. import * as type from '../types/bizTable';
  4. /* 默认状态 */
  5. export const defaultState = Immutable({
  6. loading: false,
  7. pagination: {
  8. current: 1,
  9. pageSize: 15,
  10. total: 0
  11. },
  12. data: []
  13. });
  14. export default handleActions(
  15. {
  16. // ...
  17. /* 处理获得数据成功 */
  18. [type.BIZ_TABLE_GET_RES_SUCCESS]: (state, {payload}) => {
  19. return state.merge(
  20. {
  21. loading: false,
  22. pagination: {total: payload.total},
  23. data: payload.items
  24. },
  25. {deep: true}
  26. );
  27. },
  28. // ...
  29. },
  30. defaultState
  31. );

这里的状态对象使用了 seamless-immutable。

对于 reducer,我们主要测试两个方面:

  1. 对于未知的 action.type ,是否能返回当前状态。
  2. 对于每个业务 type ,是否都返回了经过正确处理的状态。

测试代码

  1. import * as type from '@/store/types/bizTable';
  2. import reducer, { defaultState } from '@/store/reducers/bizTable';
  3. /* 测试 bizTable reducer */
  4. describe('bizTable reducer', () => {
  5. /* 测试未指定 state 参数情况下返回当前缺省 state */
  6. test('should return the default state', () => {
  7. expect(reducer(undefined, {type: 'UNKNOWN'})).toEqual(defaultState);
  8. });
  9. // ...
  10. /* 测试处理正常数据结果 */
  11. test('should handle successful data response', () => {
  12. /* 模拟返回数据结果 */
  13. const payload = {
  14. items: [
  15. {id: 1, code: '1'},
  16. {id: 2, code: '2'}
  17. ],
  18. total: 2
  19. };
  20. /* 期望返回的状态 */
  21. const expectedState = defaultState
  22. .setIn(['pagination', 'total'], payload.total)
  23. .set('data', payload.items)
  24. .set('loading', false);
  25. expect(
  26. reducer(defaultState, {
  27. type: type.BIZ_TABLE_GET_RES_SUCCESS,
  28. payload
  29. })
  30. ).toEqual(expectedState);
  31. });
  32. // ...
  33. });

selectors

selector 的作用是获取对应业务的状态,这里使用了 reselect 来做缓存,防止 state 未改变的情况下重新计算,先看一下表格的 selector 代码:

  1. import { createSelector } from 'reselect';
  2. import * as defaultSettings from '@/utils/defaultSettingsUtil';
  3. // ...
  4. const getBizTableState = (state) => state.bizTable;
  5. export const getBizTable = createSelector(getBizTableState, (bizTable) => {
  6. return bizTable.merge({
  7. pagination: defaultSettings.pagination
  8. }, {deep: true});
  9. });

selector 的作用是获取对应业务的状态,这里使用了 reselect 来做缓存,防止 state 未改变的情况下重新计算,先看一下表格的 selector 代码:

  1. import { createSelector } from 'reselect';
  2. import * as defaultSettings from '@/utils/defaultSettingsUtil';
  3. // ...
  4. const getBizTableState = (state) => state.bizTable;
  5. export const getBizTable = createSelector(getBizTableState, (bizTable) => {
  6. return bizTable.merge({
  7. pagination: defaultSettings.pagination
  8. }, {deep: true});
  9. });

这里的分页器部分参数在项目中是统一设置,所以 reselect 很好的完成了这个工作:如果业务状态不变,直接返回上次的缓存。分页器默认设置如下:

  1. export const pagination = {
  2. size: 'small',
  3. showTotal: (total, range) => `${range[0]}-${range[1]} / ${total}`,
  4. pageSizeOptions: ['15', '25', '40', '60'],
  5. showSizeChanger: true,
  6. showQuickJumper: true
  7. };

那么我们的测试也主要是两个方面:

  1. 对于业务 selector ,是否返回了正确的内容。
  2. 缓存功能是否正常。

测试代码如下:

  1. import Immutable from 'seamless-immutable';
  2. import { getBizTable } from '@/store/selectors';
  3. import * as defaultSettingsUtil from '@/utils/defaultSettingsUtil';
  4. /* 测试 bizTable selector */
  5. describe('bizTable selector', () => {
  6. let state;
  7. beforeEach(() => {
  8. state = createState();
  9. /* 每个用例执行前重置缓存计算次数 */
  10. getBizTable.resetRecomputations();
  11. });
  12. function createState() {
  13. return Immutable({
  14. bizTable: {
  15. loading: false,
  16. pagination: {
  17. current: 1,
  18. pageSize: 15,
  19. total: 0
  20. },
  21. data: []
  22. }
  23. });
  24. }
  25. /* 测试返回正确的 bizTable state */
  26. test('should return bizTable state', () => {
  27. /* 业务状态 ok 的 */
  28. expect(getBizTable(state)).toMatchObject(state.bizTable);
  29. /* 分页默认参数设置 ok 的 */
  30. expect(getBizTable(state)).toMatchObject({
  31. pagination: defaultSettingsUtil.pagination
  32. });
  33. });
  34. /* 测试 selector 缓存是否有效 */
  35. test('check memoization', () => {
  36. getBizTable(state);
  37. /* 第一次计算,缓存计算次数为 1 */
  38. expect(getBizTable.recomputations()).toBe(1);
  39. getBizTable(state);
  40. /* 业务状态不变的情况下,缓存计算次数应该还是 1 */
  41. expect(getBizTable.recomputations()).toBe(1);
  42. const newState = state.setIn(['bizTable', 'loading'], true);
  43. getBizTable(newState);
  44. /* 业务状态改变了,缓存计算次数应该是 2 了 */
  45. expect(getBizTable.recomputations()).toBe(2);
  46. });
  47. });

sagas

这里我用了 redux-saga 处理业务流,这里具体也就是异步调用 api 请求数据,处理成功结果和错误结果等。
可能有的童鞋觉得搞这么复杂干嘛,异步请求用个 redux-thunk 不就完事了吗?别急,耐心看完你就明白了。

这里有必要大概介绍下 redux-saga 的工作方式。saga 是一种 es6 的生成器函数 - Generator ,我们利用他来产生各种声明式的 effects ,由 redux-saga 引擎来消化处理,推动业务进行。
这里我们来看看获取表格数据的业务代码:

  1. import { all, takeLatest, put, select, call } from 'redux-saga/effects';
  2. import * as type from '../types/bizTable';
  3. import * as actions from '../actions/bizTable';
  4. import { getBizToolbar, getBizTable } from '../selectors';
  5. import * as api from '@/services/bizApi';
  6. // ...
  7. export function* onGetBizTableData() {
  8. /* 先获取 api 调用需要的参数:关键字、分页信息等 */
  9. const {keywords} = yield select(getBizToolbar);
  10. const {pagination} = yield select(getBizTable);
  11. const payload = {
  12. keywords,
  13. paging: {
  14. skip: (pagination.current - 1) * pagination.pageSize, max: pagination.pageSize
  15. }
  16. };
  17. try {
  18. /* 调用 api */
  19. const result = yield call(api.getBizTableData, payload);
  20. /* 正常返回 */
  21. yield put(actions.putBizTableDataSuccessResult(result));
  22. } catch (err) {
  23. /* 错误返回 */
  24. yield put(actions.putBizTableDataFailResult());
  25. }
  26. }

这个业务的具体步骤:

  1. 从对应的 state 里取到调用 api 时需要的参数部分(搜索关键字、分页),这里调用了刚才的 selector。
  2. 组合好参数并调用对应的 api 层。
  3. 如果正常返回结果,则发送成功 action 通知 reducer 更新状态。
  4. 如果错误返回,则发送错误 action 通知 reducer。

这种业务代码涉及到了 api 或其他层的调用,如果要写单元测试必须做一些 mock 之类来防止真正调用 api 层,下面我们来看一下 怎么针对这个 saga 来写测试用例:

  1. import { put, select } from 'redux-saga/effects';
  2. // ...
  3. /* 测试获取数据 */
  4. test('request data, check success and fail', () => {
  5. /* 当前的业务状态 */
  6. const state = {
  7. bizToolbar: {
  8. keywords: 'some keywords'
  9. },
  10. bizTable: {
  11. pagination: {
  12. current: 1,
  13. pageSize: 15
  14. }
  15. }
  16. };
  17. const gen = cloneableGenerator(saga.onGetBizTableData)();
  18. /* 1. 是否调用了正确的 selector 来获得请求时要发送的参数 */
  19. expect(gen.next().value).toEqual(select(getBizToolbar));
  20. expect(gen.next(state.bizToolbar).value).toEqual(select(getBizTable));
  21. /* 2. 是否调用了 api 层 */
  22. const callEffect = gen.next(state.bizTable).value;
  23. expect(callEffect['CALL'].fn).toBe(api.getBizTableData);
  24. /* 调用 api 层参数是否传递正确 */
  25. expect(callEffect['CALL'].args[0]).toEqual({
  26. keywords: 'some keywords',
  27. paging: {skip: 0, max: 15}
  28. });
  29. /* 3. 模拟正确返回分支 */
  30. const successBranch = gen.clone();
  31. const successRes = {
  32. items: [
  33. {id: 1, code: '1'},
  34. {id: 2, code: '2'}
  35. ],
  36. total: 2
  37. };
  38. expect(successBranch.next(successRes).value).toEqual(
  39. put(actions.putBizTableDataSuccessResult(successRes)));
  40. expect(successBranch.next().done).toBe(true);
  41. /* 4. 模拟错误返回分支 */
  42. const failBranch = gen.clone();
  43. expect(failBranch.throw(new Error('模拟产生异常')).value).toEqual(
  44. put(actions.putBizTableDataFailResult()));
  45. expect(failBranch.next().done).toBe(true);
  46. });

saga 实际上是返回各种声明式的 effects ,然后由引擎来真正执行。所以我们测试的目的就是要看 effects 的产生是否符合预期。那么 effect 到底是个神马东西呢?其实就是字面量对象!

我们可以用在业务代码同样的方式来产生这些字面量对象,对于字面量对象的断言就非常简单了,并且没有直接调用 api 层,就用不着做 mock 咯!这个测试用例的步骤就是利用生成器函数一步步的产生下一个 effect ,然后断言比较。

redux-saga 还提供了一些辅助函数来方便的处理分支断点。

api 和 fetch 工具库

接下来就是api 层相关的了。前面讲过调用后台请求是用的 fetch ,我封装了两个方法来简化调用和结果处理: getJSON() 、 postJSON() ,分别对应 GET 、POST 请求。先来看看 api 层代码:

  1. import { fetcher } from '@/utils/fetcher';
  2. export function getBizTableData(payload) {
  3. return fetcher.postJSON('/api/biz/get-table', payload);
  4. }
  1. import sinon from 'sinon';
  2. import { fetcher } from '@/utils/fetcher';
  3. import * as api from '@/services/bizApi';
  4. /* 测试 bizApi */
  5. describe('bizApi', () => {
  6. let fetcherStub;
  7. beforeAll(() => {
  8. fetcherStub = sinon.stub(fetcher);
  9. });
  10. // ...
  11. /* getBizTableData api 应该调用正确的 method 和传递正确的参数 */
  12. test('getBizTableData api should call postJSON with right params of fetcher', () => {
  13. /* 模拟参数 */
  14. const payload = {a: 1, b: 2};
  15. api.getBizTableData(payload);
  16. /* 检查是否调用了工具库 */
  17. expect(fetcherStub.postJSON.callCount).toBe(1);
  18. /* 检查调用参数是否正确 */
  19. expect(fetcherStub.postJSON.lastCall.calledWith('/api/biz/get-table', payload)).toBe(true);
  20. });
  21. });

由于 api 层直接调用了工具库,所以这里用 sinon.stub() 来替换工具库达到测试目的。
接着就是测试自己封装的 fetch 工具库了,这里 fetch 我是用的 isomorphic-fetch ,所以选择了 nock 来模拟 Server 进行测试,主要是测试正常访问返回结果和模拟服务器异常等,示例片段如下:

  1. import nock from 'nock';
  2. import { fetcher, FetchError } from '@/utils/fetcher';
  3. /* 测试 fetcher */
  4. describe('fetcher', () => {
  5. afterEach(() => {
  6. nock.cleanAll();
  7. });
  8. afterAll(() => {
  9. nock.restore();
  10. });
  11. /* 测试 getJSON 获得正常数据 */
  12. test('should get success result', () => {
  13. nock('http://some')
  14. .get('/test')
  15. .reply(200, {success: true, result: 'hello, world'});
  16. return expect(fetcher.getJSON('http://some/test')).resolves.toMatch(/^hello.+$/);
  17. });
  18. // ...
  19. /* 测试 getJSON 捕获 server 大于 400 的异常状态 */
  20. test('should catch server status: 400+', (done) => {
  21. const status = 500;
  22. nock('http://some')
  23. .get('/test')
  24. .reply(status);
  25. fetcher.getJSON('http://some/test').catch((error) => {
  26. expect(error).toEqual(expect.any(FetchError));
  27. expect(error).toHaveProperty('detail');
  28. expect(error.detail.status).toBe(status);
  29. done();
  30. });
  31. });
  32. /* 测试 getJSON 传递正确的 headers 和 query strings */
  33. test('check headers and query string of getJSON()', () => {
  34. nock('http://some', {
  35. reqheaders: {
  36. 'Accept': 'application/json',
  37. 'authorization': 'Basic Auth'
  38. }
  39. })
  40. .get('/test')
  41. .query({a: '123', b: 456})
  42. .reply(200, {success: true, result: true});
  43. const headers = new Headers();
  44. headers.append('authorization', 'Basic Auth');
  45. return expect(fetcher.getJSON(
  46. 'http://some/test', {a: '123', b: 456}, headers)).resolves.toBe(true);
  47. });
  48. // ...
  49. });

参考回答

单元检测

在单元检测的时候,常用的方法论是: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

普通测试

  1. import { expect } from 'chai';
  2. import add from '../src/common/add';
  3. describe('加法函数测试', () => {
  4. it('应该返回两个数之和', () => {
  5. expect(add(4, 5)).to.be.equal(9);
  6. });
  7. });
  8. /*
  9. 加法函数测试
  10. √ 应该返回两个数之和
  11. 1 passing
  12. */

异步函数测试

  1. import { expect } from 'chai';
  2. import asyncFn from('../src/common/asyncFn');
  3. describe('异步函数测试', () => {
  4. it('async with done', async () => {
  5. const res = await asyncFn();
  6. expect(res).to.have.deep.property('status', 200);
  7. });
  8. });
  9. /*
  10. 异步函数测试
  11. √ async with done (176ms)
  12. 1 passing
  13. */