# 先说一下示例怎么运行,先确定本机安装好 node 环境
# 安装项目依赖
npm install
# 首先启动 webpack-dev-server
npm run start-dev
# 上一个运行完毕后不要关闭,开一个新的命令行,启动 node server 服务
npm run start-server
#上述两个启动好后打开浏览器访问 http://localhost:3000 即可
# 跑测试用例
npm test
# 生成测试覆盖报告,跑完后看 coverage 子目录下的内容
npm run test-coverage
# 以上脚本定义都在 package.json 中По мере того, как веб-приложения становятся все более и более сложными, многие компании уделяют все больше внимания и больше внимания к фронтальному модульному тестированию. Большинство учебных пособий, которые мы видим, будут говорить о важности модульного тестирования и о том, как использовать некоторые репрезентативные API -интерфейсы тестирования, но как начать модульные тестирование в реальных проектах? Какой конкретный контент должен включать в себя тестовые примеры?
Эта статья начинается с реального сценария приложения, анализируется, какие модульные тесты должны содержать из структуры проектирования и структуры кода, и как писать конкретные тестовые примеры. Я надеюсь, что вы сможете что -то получить от детской обуви, которую вы видите.
Проект использует технологический стек react , а основные используемые рамки включают: react , redux , react-redux , reselect redux-actions , redux-saga , seamless-immutable , а также, antd .

Из слоя пользовательского интерфейса этот сценарий применения в основном состоит из двух частей:
Когда вы увидите здесь детскую обувь, вы можете сказать: Cut! Такой простой интерфейс и бизнес -логика все еще настоящий сценарий? Вам все еще нужно написать Shenma Unit Tests?
Не волнуйтесь, чтобы обеспечить опыт чтения и продолжительность статьи, простая сцена, которая может ясно объяснить проблему, хорошая сцена, верно? Посмотрите медленно.
В этом сценарии проектирование и разработку мы строго соблюдаем передовые методы redux одностороннего потока данных и react-redux и используем redux-saga для обработки бизнес-потока, reselect для обработки кэша состояния и вызовы интерфейсов бэкэнд через fetch , что ничем не отличается от реальных проектов.
Слоистая проектная и кодовая организация заключаются в следующем:

Содержимое в среднем store связано с redux , и вы должны знать смысл, глядя на имя.
Для конкретных кодов, пожалуйста, посмотрите здесь.
Давайте сначала поговорим о том, какие рамки тестирования и инструменты используются, основное содержание включает в себя:
jest , тестовая структураenzyme , специализирующийся на тестировании React UI -слояsinon имеет независимую библиотеку подделок, шпионов, заглушек и насмешекnock , моделируйте HTTP -серверЕсли у вас есть детская обувь, которая не знакомы с приведенным выше использованием и конфигурацией, просто прочитайте официальную документацию, которая лучше, чем любой учебник.
Далее мы начинаем писать конкретный тестовый код примера. Следующее будет давать кодовые фрагменты и анализы для каждого уровня. Тогда давайте начнем с 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 , которые до сих пор используют handleActions redux-actions для написания 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 ) ;
} ) ;
// ...
} ) ;Логика тестового примера здесь также очень проста, и она все еще является процедурой утверждения ожидаемого результата выше. Ниже приведена часть селекторов.
Функция selector заключается в получении статуса соответствующего бизнеса. Здесь используется reselect для предотвращения пересчета при изменении state . Давайте сначала посмотрим на код селектора таблицы:
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 } ) ;
} ) ;Параметры пейджера здесь одинаково установлены в проекте, поэтому перепродажи хорошо выполняют эту задачу: если статус бизнеса остается неизменным, он напрямую вернется к последнему кэше. Настройки по умолчанию устройства пейджанга следующие:
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 ) ;
} ) ;
} ) ;Тестовый пример все еще очень прост? Просто держи этот темп. Давайте поговорим о слегка сложной части, о сагах.
Здесь я использую redux-saga для обработки бизнес-потока, который специально называется API для асинхронного запроса данных, успешных результатов и результатов ошибок и т. Д.
Некоторые детские туфли могут подумать, что это так сложно. Разве все не закончилось использовать redux-thunk Asynchrony, запрошенный? Не волнуйтесь, вы поймете после терпеливо прочтения.
Здесь необходимо кратко ввести метод работы redux-saga . SAGA - это функция генератора es6 - генератор, который мы используем для генерации различных декларативных 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 или других слоев. Если вы хотите написать модульные тесты, вы должны сделать несколько макетов, чтобы не было вызвать слой API. Давайте посмотрим, как написать тестовые примеры для этой саги:
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! Шаг этого тестового примера состоит в том, чтобы использовать функцию генератора для генерации следующего effect шаг за шагом, а затем утверждать и сравнить.
Как видно из комментариев 3 и 4 выше,
redux-sagaтакже предоставляет несколько вспомогательных функций для легкости обработки точек останова ветви.
Вот почему я выбрал redux-saga : она мощно и способствует тестированию.
Далее связано с слоем API. Как упоминалось ранее, я использовал fetch для вызова фоновых запросов. Я инкапсулировал два метода для упрощения обработки вызовов и результатов: getJSON() и postJSON() , соответствующие запросам GET и POPL соответственно. Давайте посмотрим на код слоя 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, которую вы инкапсулировали. Здесь я использую isomorphic-fetch , поэтому я выбрал nock для моделирования сервера для тестирования, в основном проверяя результаты нормального доступа и моделирования исключений сервера и т. Д. Пример фрагмент заключается в следующем:
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 - это обещание возврата, и различные асинхронные решения для тестирования jest могут быть встречены очень хорошо.
Остальное связано с пользовательским интерфейсом.
Основная цель компонента контейнера - пройти состояние и действия. Посмотрите на код компонента контейнера на панели инструментов:
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));
});
});
Это очень просто, так что нечего сказать.
Здесь мы принимаем компонент таблицы в качестве примера, и мы прямо рассмотрим, как написан тестовый пример. Вообще говоря, мы в основном проверяем следующие аспекты компонентов пользовательского интерфейса:
Вот тестовый код примера:
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 Projects. Полный кодовый контент здесь (я скажу более важные вещи, я думаю, что мне легко помочь).
Наконец, мы можем использовать скорость покрытия, чтобы увидеть, достаточно ли уровень покрытия варианта использования (как правило, нет необходимости намеренно преследовать 100%, в зависимости от фактической ситуации):

Единое тестирование является основой разработки TDD-испытаний. Из приведенного выше процесса мы видим, что хорошая иерархия дизайна легко писать тестовые примеры, а модульное тестирование - это не только для обеспечения качества кода: заставит ли вы подумать о рациональности дизайна кода и отклонить код лапши?
Чтобы одолжить заключение чистого кода:
В 2005 году, посещая конференцию по гибкости в Денвере, Элизабет Хедриксон передала мне зеленый браслет, похожий на тот, который был продан Лэнсом Армстронгом. В браслере говорится «тест одержимой». Я надел это с радостью и с гордостью продолжал. Я действительно был одержим тестовым разработкой с тех пор, как узнал о TDD от Кента Бек в 1999 году.
Но случилось что -то странное. Я обнаружил, что не могу удалить ремешок. Не только потому, что браслет очень плотный, но это также духовное заклинание. Этот браслет является декларацией моей трудовой этики и совет для моего обязательства сделать все возможное, чтобы написать лучший код. Снимите это, как будто это было против этих заявлений и обещаний.
Так что это все еще на моем запястье. Во время написания кода я видел его с нижней части глаза. Это продолжало напоминать мне, что я пообещал написать аккуратный код.