随着 Web 应用的复杂程度愈来愈高,不少公司愈来愈重视前端单元测试。咱们看到的大多数教程都会讲单元测试的重要性、一些有表明性的测试框架 api 怎么使用,但在实际项目中单元测试要怎么下手?测试用例应该包含哪些具体内容呢?javascript
本文从一个真实的应用场景出发,从设计模式、代码结构来分析单元测试应该包含哪些内容,具体测试用例怎么写,但愿看到的童鞋都能有所收获。
完整的代码内容在 这里 (各位童鞋以为好帮忙去给个 哈)。前端
该项目采用 react
技术栈,用到的主要框架包括:react
、redux
、react-redux
、redux-actions
、reselect
、redux-saga
、seamless-immutable
、antd
。java
这个应用场景从 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
开始吧。
为使文章尽可能简短、清晰,下面的代码片断不是每一个文件的完整内容,完整内容在 这里 。
业务里面我使用了 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
,依然采用 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
,是否能返回当前状态。下面是针对以上两点的测试代码:
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}); });
这里的分页器部分参数在项目中是统一设置,因此 reselect 很好的完成了这个工做:若是业务状态不变,直接返回上次的缓存。分页器默认设置以下:
export const pagination = { size: 'small', showTotal: (total, range) => `${range[0]}-${range[1]} / ${total}`, pageSizeOptions: ['15', '25', '40', '60'], showSizeChanger: true, showQuickJumper: true };
那么咱们的测试也主要是两个方面:
测试代码以下:
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()); } }
不熟悉 redux-saga
的童鞋也不要太在乎代码的具体写法,看注释应该能了解这个业务的具体步骤:
state
里取到调用 api 时须要的参数部分(搜索关键字、分页),这里调用了刚才的 selector。那么具体的测试用例应该怎么写呢?咱们都知道这种业务代码涉及到了 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
,我封装了两个方法来简化调用和结果处理: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 组件咱们主要测试如下几个方面:
下面是测试用例代码:
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
来达到测试目的,结合 enzyme
和 sinon
,测试用例依然保持简单的节奏。
以上就是这个场景完整的测试用例编写思路和示例代码,文中说起的思路方法也彻底能够用在 Vue
、Angular
项目上。完整的代码内容在 这里 (重要的事情多说几遍,各位童鞋以为好帮忙去给个 哈)。
最后咱们能够利用覆盖率来看下用例的覆盖程度是否足够(通常来讲不用刻意追求 100%,根据实际状况来定):
单元测试是 TDD 测试驱动开发的基础。从以上整个过程能够看出,好的设计分层是很容易编写测试用例的,单元测试不仅仅只是为了保证代码质量:他会逼着你思考代码设计的合理性,拒绝面条代码
借用 Clean Code 的结束语:
2005 年,在参加于丹佛举行的敏捷大会时,Elisabeth Hedrickson 递给我一条相似 Lance Armstrong 热销的那种绿色腕带。这条腕带上面写着“沉迷测试”(Test Obsessed)的字样。我高兴地戴上,并自豪地一直系着。自从 1999 年从 Kent Beck 那儿学到 TDD 以来,个人确迷上了测试驱动开发。不过跟着就发生了些奇事。我发现本身没法取下腕带。不只是由于腕带很紧,并且那也是条精神上的紧箍咒。那腕带就是我职业道德的宣告,也是我承诺尽己所能写出最好代码的提示。取下它,仿佛就是违背了这些宣告和承诺似的。
因此它还在个人手腕上。在写代码时,我用余光瞟见它。它一直提醒我,我作了写出整洁代码的承诺。