Сага о тестировании: библиотека redux-saga-test-plan
Фреймворк redux-saga
предоставляет кучу интересных паттернов для работы с сайд-эффектами, но, как истинные кроваво-энтерпрайзные разработчики, мы должны покрывать весь свой код тестами. Давайте разберёмся, как мы будем тестировать наши саги.
Возьмем простейший кликер в качестве примера. Поток данных и смысл приложения будет таким:
- Юзер тыкает в кнопку.
- На сервер отправляется запрос, сообщающий, что юзер тыкнул в кнопку.
- Сервер возвращает количество сделанных кликов.
- В стейт записывается количество сделанных кликов.
- Обновляется UI, и юзер видит, что количество кликов увеличилось.
- …
- PROFIT.
В работе мы используем Typescript, поэтому все примеры будут именно на этом языке.
Как вы уже, наверное, догадались, реализовывать это всё мы будем с помощью redux-saga
. Приведу здесь код файла с сагами целиком:
export function* processClick() {
const result = yield call(ServerApi.SendClick)
yield put(Actions.clickSuccess(result))
}
export function* watchClick() {
yield takeEvery(ActionTypes.CLICK, processClick)
}
В этом простом примере мы объявляем сагу processClick
, которая непосредственно обрабатывает action и сагу watchClick
, которая создаёт цикл обработки action’ов
.
Генераторы
Итак, у нас есть простейшая сага. Она отправляет запрос на сервер (эффект call)
, получает результат и передаёт его в reducer (эффект put)
. Нам нужно каким-то образом протестировать, передаёт ли сага именно то, что получает от сервера. Приступим.
Для тестирования нам понадобится замокать серверный вызов и каким-то образом проверить, ушло ли в редюсер именно то, что пришло с сервера.
Так как саги — это функции-генераторы, самым очевидным путем для тестирования будет метод next()
, который есть в прототипе генератора. При использовании этого метода у нас есть возможность как получать очередное значение из генератора, так и передавать значение в генератор. Таким образом мы из коробки получаем возможность мокать вызовы. Но всё ли так радужно? Вот тест, который я написал на голых генераторах:
it('should increment click counter (behaviour test)', () => {
const saga = processClick()
expect(saga.next().value).toEqual(call(ServerApi.SendClick))
expect(saga.next(10).value).toEqual(put(Actions.clickSuccess(10)))
})
Тест получился лаконичным, но что он тестирует? По сути, он просто повторяет код метода саги, то есть при любом изменении саги придется менять и тест.
Такой тест ничем не помогает в разработке.
Redux-saga-test-plan
После столкновения с этой проблемой, мы решили погуглить и внезапно поняли, что мы такие не единственные и далеко не первые. Прямо в документации к redux-saga
разработчики предлагают взглянуть на несколько библиотек, созданных специально для удовлетворения фанатов тестирования.
Из предложенного списка мы взяли библиотеку redux-saga-test-plan
. Вот код первой версии теста, который я написал с её помощью:
it('should increment click counter (behaviour test with test-plan)', () => {
return expectSaga(processClick)
.provide([
call(ServerApi.SendClick), 2]
])
.dispatch(Actions.click())
.call(ServerApi.SendClick)
.put(Actions.clickSuccess(2))
.run()
})
Конструктором теста в redux-saga-test-plan
является функция expectSaga
, возвращающая интерфейс, которым описывается тест. В саму функцию передаётся тестируемая сага (processClick
из первого листинга).
С помощью метода provide
можно замокать вызовы сервера или другие зависимости. В неё передаётся массив из StaticProvider’ов
, которые описывают какой метод что должен возвращать.
В блоке Act
у нас один единственный метод — dispatch
. В него передаётся action, на который будет реагировать сага.
Блок assert
состоит из методов call и put
, проверяющих были ли в ходе работы саги вызваны соответствующие эффекты.
Заканчивается это всё методом run()
. Этот метод непосредственно запускает тест.
Плюсы такого подхода:
- проверяется, был ли вызван метод, а не последовательность вызовов;
- моки явно описывают, какая функция мокается и что возвращается.
Однако есть над чем поработать:
- кода стало больше;
- тест сложно читать;
- это тест на поведение, а значит он всё-таки связан с реализацией саги.
Два последних штриха
Тест на состояние
Сначала исправим последнее: сделаем из теста на поведение тест на состояние. В этом нам поможет тот факт, что test-plan
позволяет задать начальный state
и передать reducer
, который должен реагировать на эффекты put
, порождаемые сагой. Выглядит это так:
it('should increment click counter (state test with test-plan)', () => {
const initialState = {
clickCount: 11,
return expectSaga(processClick)
.provide([
call(ServerApi.SendClick), 14]
])
.withReducer(rootReducer, initialState)
.dispatch(Actions.click())
.run()
.then(result => expect(result.storeState.clickCount).toBe(14))
})
В этом тесте мы уже не проверяем, что были вызваны какие-либо эффекты. Мы проверяем итоговый стейт после выполнения, и это прекрасно.
Нам удалось отвязаться от реализации саги, теперь попробуем сделать тест более понятным. Это легко, если заменить then()
на async/await
:
it('should increment click counter (state test with test-plan async-way)', async () => {
const initialState = {
clickCount: 11,
}
const saga = expectSaga(processClick)
.provide([
call(ServerApi.SendClick), 14]
])
.withReducer(rootReducer, initialState)
const result = await saga.dispatch(Actions.click()).run()
expect(result.storeState.clickCount).toBe(14)
})
Интеграционные тесты
А что если у нас появилась ещё и обратная клику операция (назовём её unclick), и теперь наш файл с сагами выглядит вот так:
export function* processClick() {
const result = yield call(ServerApi.SendClick)
yield put(Actions.clickSuccess(result))
}
export function* processUnclick() {
const result = yield call(ServerApi.SendUnclick)
yield put(Actions.clickSuccess(result))
}
function* watchClick() {
yield takeEvery(ActionTypes.CLICK, processClick)
}
function* watchUnclick() {
yield takeEvery(ActionTypes.UNCLICK, processUnclick)
}
export default function* mainSaga() {
yield all([watchClick(), watchUnclick()])
}
Допустим, нам нужно протестировать, что при последовательном вызове action«ов click и unclick в state запишется результат последнего похода на сервер. Такой тест также можно легко сделать с помощью redux-saga-test-plan
:
it('should change click counter (integration test)', async () => {
const initialState = {
clickCount: 11,
}
const saga = expectSaga(mainSaga)
.provide([
call(ServerApi.SendClick), 14],
call(ServerApi.SendUnclick), 18]
])
.withReducer(rootReducer, initialState)
const result = await saga
.dispatch(Actions.click())
.dispatch(Actions.unclick())
.run()
expect(result.storeState.clickCount).toBe(18)
})
Обратите внимание, теперь мы тестируем mainSaga
, а не отдельные обработчики action«ов.
Однако, если мы запустим этот тест как есть, то получим ворнинг:
Это происходит из-за эффекта takeEvery
— это цикл обработки сообщений, который будет работать, пока открыто наше приложение. Соответственно, тест, в котором вызывается takeEvery
не сможет без посторонней помощи завершить работу, и redux-saga-test-plan
принудительно завершает работу таких эффектов через 250 мс после начала теста. Этот таймаут можно изменить с помощью вызова expectSaga.DEFAULT_TIMEOUT = 50.
Если же вы не хотите получать такие ворнинги по одному на каждый тест со сложным эффектом, просто используйте вместо методаrun()
методsilentRun()
.
Подводные камни
Куда же без подводных камней… На момент написания этой статьи, последняя версия redux-saga: 1.0.2. В то же время redux-saga-test-plan
пока умеет работать с ней только на JS.
Если хотите TypeScript, придется ставить версию из beta-канала: npm install redux-saga-test-plan@beta
и выключить из билда тесты. Для этого в файле tsconfig.json нужно прописать путь »./src/**/*.spec.ts» в поле «exclude».
Несмотря на это, мы считаем redux-saga-test-plan
самой лучшей библиотекой для тестирования redux-saga
. Если у вас в проекте есть redux-saga
, возможно, она станет для вас хорошим выбором.
Исходный код примера на GitHub.