Декларативные эффекты
В redux-saga
саги реализованы с использованием функций генераторов. Чтобы выразить логику сагов, мы используя ключевоео слово yield
и передаем простые объекты JavaScript из генератора. Мы называем их объекты Effects. Effect это простой объект, который содержит информацию, которая должна интерпретироваться с помощью middleware. Вы можете просматривать эффекты, как инструкции в middleware которые выполняют какую-то операцию (вызовите любую асинхронную функцию и диспачните её в ваш Store).
Чтобы создать effects, вы можете используете функции которые находятся в пакете redux-saga/effects
.
В этом разделе и ниже мы раскажем об основных Effects-ах. И рассмотрим, как эта концепция позволяет легко тестировать вашии саги.
Саги могут отдавать Effect в нескольких формах. Самый простой способ используя ключивое слово yield
вернуть Promise
.
Например, предположим, что у нас есть сага, которая следит за action PRODUCT_REQUESTED
. При каждом диспатче action PRODUCT_REQUESTED
, запускается задача которая получает список продуктов с сервера.
import { takeEvery } from 'redux-saga/effects'
import Api from './path/to/api'
function* fetchProducts() {
const products = yield Api.fetch('/products')
console.log(products)
}
function* watchFetchProducts() {
yield takeEvery('PRODUCTS_REQUESTED', fetchProducts)
}
В приведенном выше примере мы вызываем Api.fetch
непосредственно изнутри генератора (в функциях генераторах, любое выражение справа от yield
вычисляется, и только потом отдается).
Api.fetch('/products')
запускает AJAX-запрос и возвращает Promise that will resolve with the resolved response, the AJAX request will be executed immediately.
Просто, но...
Предположим, мы хотим создать тест для генератор выше:
const iterator = fetchProducts()
assert.deepEqual(iterator.next().value, ??) // что мы ожидаем?
Мы хотим проверить результат первого значения, полученного из генератора. В нашем случае это результат выполнения Api.fetch('/products')
, который является Promise-ом.
Выполнение реального сервиса во время тестов не является ни жизнеспособным, ни практическим подходом, поэтому приходится мокать результат функции Api.fetch
, т. е. мы должны заменить реальные функции фейковыми, которая на самом деле не делают AJAX-запрос, а только проверяет, что Api.fetch
с правильными аргументами ('/products'
в нашем случае).
Моки делают тестирование более сложным и менее надежным. С другой стороны, функции которая просто возвращают значения легче проверить, так как мы можем простую использовать equal()
, чтобы проверить результат.
Это способ позволяет написать самые надежные тесты.
Не верите? Советую вам прочитать Eric Elliott's article:
(...)
equal()
, by nature answers the two most important questions every unit test must answer, but most don’t:
- What is the actual output?
- What is the expected output?
Если вы закончите тест, не отвечая на эти два вопроса, у вас нет реального модульного теста. У вас будут только неаккуратные тесты.
Нам нужно просто чтобы убедиться, что таск fetchProducts
вызывает с использованием yield правильную функцию с правильным набором аргументов.
Вместо вызова асинхронной функции непосредственно из Генератора, мы можем вернуть только описание вызова функции. т. е. мы просто вернем объект, который выглядит следующим образом
// Effect -> вызывает функцию Api.fetch с аргументом `./products`
{
CALL: {
fn: Api.fetch,
args: ['./products']
}
}
Иными словами, генератор будет отдавать простые объекты, содержащие инструкции и middleware redux-saga
будет заботиться о выполнении этих поручений и результат их выполнения для генератора. Таким образом, при тестировании Генератора, все, что нам нужно сделать, это проверить, что он отдает ожидаемую инструкцию, просто вызывая deepEqual
на данном объекте.
По этой причине библиотека предоставляет другой способ выполнения асинхронных вызовов.
import { call } from 'redux-saga/effects'
function* fetchProducts() {
const products = yield call(Api.fetch, '/products')
// ...
}
import { call } from 'redux-saga/effects'
import Api from '...'
const iterator = fetchProducts()
// expects a call instruction
assert.deepEqual(
iterator.next().value,
call(Api.fetch, '/products'),
"fetchProducts should yield an Effect call(Api.fetch, './products')"
)
Теперь мы используем функцию call(fn, ...args)
. Отличие от предыдущего примера заключается в том, что теперь мы не будем получать данные используя fetch
сразу, вместо этого мы вызовем call
для создания описания Effect-а.
Так же, как в Redux вы используете action creator для создания простого объекта, описывающего действие, которое будет выполняться Store, call
создает простой объект, описывающий вызов функции. Middleware в Redux-saga заботится о выполнении вызова функции и возобновлении работы генератора с полученым ответом.
Это позволяет легко проверить генератор вне среды Redux-а. Потому что call
это просто функция, которая возвращает простой объект.
import { call } from 'redux-saga/effects'
import Api from '...'
const iterator = fetchProducts()
// ожидается вызов
assert.deepEqual(
iterator.next().value,
call(Api.fetch, '/products'),
"fetchProducts should yield an Effect call(Api.fetch, './products')"
)
Теперь нам не нужно мокать какие-либо данные и нам достаточно простой проверки полученных данных на равенство.
Преимущество этих декларативных вызовов состоит в том, что мы можем проверить всю логику внутри саги, просто проводя итерацию генератора и выполняя deepEqual
с значением полученным последовательно из генератора. Это реальная выгода, так как ваши сложные асинхронные операции больше не являются черными ящиками, и вы можете детально проверить их логику независимо от того, насколько она сложна.
call
также поддерживает вызов объектных методов, вы можете представить this
в контексте вызываемой функции, используя следующую форму:
yield call([obj, obj.method], arg1, arg2, ...) // as if we did obj.method(arg1, arg2 ...)
apply
является алиасом для формы вызова метода
yield apply(obj, obj.method, [arg1, arg2, ...])
call
и apply
хорошо подходят для функций, которые возвращают результаты в виде Promise.
Другая функция cps
может использоваться для обработки функций в стиле Node (например,fn(...args, callback)
, где callback
имеет вид (error, result) => ()
).
Пример:
import { cps } from 'redux-saga/effects'
const content = yield cps(readFile, '/path/to/file')
И, конечно, вы можете написать тест для него также же, как бы вы написали для call
:
import { cps } from 'redux-saga/effects'
const iterator = fetchSaga()
assert.deepEqual(iterator.next().value, cps(readFile, '/path/to/file') )
cps
также поддерживает ту же форму вызова метода, что иcall
.