Фреймворк
Возьмем простейший кликер в качестве примера. Поток данных и смысл приложения будет таким:
В работе мы используем Typescript, поэтому все примеры будут именно на этом языке.
Как вы уже, наверное, догадались, реализовывать это всё мы будем с помощью
В этом простом примере мы объявляем сагу
Итак, у нас есть простейшая сага. Она отправляет запрос на сервер
Для тестирования нам понадобится замокать серверный вызов и каким-то образом проверить, ушло ли в редюсер именно то, что пришло с сервера.
Так как саги – это функции-генераторы, самым очевидным путем для тестирования будет метод
Тест получился лаконичным, но что он тестирует? По сути, он просто повторяет код метода саги, то есть при любом изменении саги придется менять и тест.
После столкновения с этой проблемой, мы решили погуглить и внезапно поняли, что мы такие не единственные и далеко не первые. Прямо в документации к
Из предложенного списка мы взяли библиотеку
Конструктором теста в
С помощью метода
В блоке
Блок
Заканчивается это всё методом
Сначала исправим последнее: сделаем из теста на поведение тест на состояние. В этом нам поможет тот факт, что
В этом тесте мы уже не проверяем, что были вызваны какие-либо эффекты. Мы проверяем итоговый стейт после выполнения, и это прекрасно.
Нам удалось отвязаться от реализации саги, теперь попробуем сделать тест более понятным. Это легко, если заменить
А что если у нас появилась ещё и обратная клику операция (назовём её unclick), и теперь наш файл с сагами выглядит вот так:
Допустим, нам нужно протестировать, что при последовательном вызове action’ов click и unclick в state запишется результат последнего похода на сервер. Такой тест также можно легко сделать с помощью
Обратите внимание, теперь мы тестируем
Однако, если мы запустим этот тест как есть, то получим ворнинг:
Это происходит из-за эффекта
Куда же без подводных камней… На момент написания этой статьи, последняя версия redux-saga: 1.0.2. В то же время
Если хотите TypeScript, придется ставить версию из beta-канала:
и выключить из билда тесты. Для этого в файле tsconfig.json нужно прописать путь «./src/**/*.spec.ts» в поле «exclude».
Несмотря на это, мы считаем
Исходный код примера на GitHub.
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.