Эффектор — это компактный и производительный менеджер состояний. Одно из его основных преимуществ — удобство при написании тестов. Ниже инструкция с подробным разбором каждой фичи.
Дисклеймер. В примерах кода используется jest в качестве тест-раннера. Если вы используете другой пакет для запуска тестов в своём проекте, примеры могут не заработать «эз из».
Принципы написания хороших модульных тестов
Модульные тесты, несмотря на кажущуюся простоту, являются довольно сложной практикой тестирования. Написать тест-кейс, который не сломается от малейшего рефакторинга и будет тестировать хоть что-то, не так просто. Рассмотрим несколько принципов, которые помогают писать модульные тесты правильно.
Изоляция тест-кейсов
На мой взгляд, самое важное в модульных тестах — независимость отдельных кейсов. Например, посмотрим на набор тестов:
describe('event emitter', () => {
let emitter
beforeAll(() => {
emitter = new Emitter()
})
afterAll(() => {
emitter.destoroy()
})
test(() => {
// ...
})
test(() => {
// ...
})
})
Этот набор имеет одну важную проблему — все тест-кейсы используют один и тот же инстанс для выполнения операций. При этом, с точки зрения теста, нет никакой гарантии, что после выполнения кейса, в инстансе не останется некоторого внутреннего состояния, которое будет влиять на все остальные кейсы. Обычно, это проблему исправляют примерно так:
describe('event emitter', () => {
let emitter
// beforeAll -> beforeEach
beforeEach(() => {
emitter = new Emitter()
})
// beforeAll -> beforeEach
afterEach(() => {
emitter.destoroy()
})
test(() => {
// ...
})
test(() => {
// ...
})
})
Теперь, тест-кейсы действительно не могут повлиять друг на друга, в случае, когда они выполняются последовательно. Но если захочется запустить их параллельно (например, если в них много асинхронных операций), то проблема вернётся. В идеальном мире, модельные тест-кейсы должны быть написаны примерно так:
describe('event emitter', () => {
test(() => {
let emitter = new Emitter()
// ...
emitter.destroy()
})
test(() => {
let emitter = new Emitter()
// ...
emitter.destroy()
})
})
В таком случае, тест-кейсы можно запускать в любом порядке, последовательно или параллельно, на одной машине или на разных. Это обеспечивает стабильность и производительность тестов.
Ограничение сайд-эффектов
Однажды мы в Авиасейлс писали модуль отправки сообщений в саппорт с сайта. И, конечно же, покрыли его модульными тестами. Через пару дней в чатик пришли ребята из саппорта. В их систему летели сотни сообщений от автора «Тест Тест» с текстом «Тестовый тест для теста». Неловко!
Если модульный тест написан в стиле «чёрной коробки», то есть не знает о внутреннем устройстве тестируемого модуля, то изолировать все сайд-эффекты бывает сложно.
Когда сайд-эффекты обнаружены, их нужно подменить в тестовом окружении на другие сайд-эффекты. Две самые известные тактики: моки на уровне импортов и внедрение зависимостей. К сожалению, во фронтенде более популярна первая.
describe('event emitter', () => {
test(() => {
let handler = jest.fn()
// подменяем модуль http-client на уровне импортов
jest.mock('http-client', () => ({
post: handler,
}));
let emitter = new Emitter()
// ...
emitter.destroy()
})
test(() => {
let handler = jest.fn()
let httpClient = { post: handler }
// явно внедряем зависимость в тестируемый модуль
let emitter = new Emitter({ httpClient })
// ...
emitter.destroy()
})
})
Первый способ более хрупкий, зато простой и не требует вносить правки в тестируемый код. Второй способ более надежный, но чтобы им пользоваться, нужно писать тестируемые модули особенным образом.
Тесты Эффектор-модулей
Сначала пример:
describe('support service', () => {
test('should send message after submit', async () => {
// создаём фейковый обработчик для сайд-эффекта
let sendMessageToSupportHandler = jest.fn()
// создаём независимую копию всего приложения
// чтобы один тест не мог повлиять на другой
let scope = fork({
handlers: new Map([
// и для этой копии заменяем обработчик отправки
[support.sendMessageFx, sendMessageToSupportHandler]
])
})
// имитируем ввод пользователем сообщения
await allSettled(support.messageChanged, {
params: 'Test Message',
scope,
})
// имитируем ввод пользователем имени
await allSettled(support.nameChanged, {
params: 'Test User',
scope,
})
// имитируем сабмит формы пользователем
await allSettled(support.formSubmitted, {
scope,
})
// проверяем, что сообщение с параметрами отправилось
expect(sendMessageToSupportHandler).toHaveBeedCalledWith({
name: 'Test User',
message: 'Test Message',
})
})
})
Этот тест следует принципам хорошего модульного теста:
- из-за создания скоупа, выполняемые в нем действия не могут затронуть другие тесты;
- благодаря встроенной возможности подменить обработчики эффектов при форке, изоляция сайд-эффектов достигается без переписывания оригинального кода и грязных хаков с подменой импортов. Теперь рассмотрим подробнее, как это все работает и зачем нужно.
fork()
До 22 версии Эффектора, в
fork
нужно было передавать домен. Теперь можно не передавать.
Функция fork
создает скоуп (scope
) — независимую изолированную копию приложения. Любые вычисления на скоупе не затрагивают основное приложение и другие скоупы.
let scope = fork()
Все связи юнитов будут работать как в оригинальном приложении.
fork({ handlers })
Одна из важнейших особенностей скоупов — это возможность подменять обработчики любых эффектов в конкретном скоупе. Для этого нужно передать в функцию fork
объект с полем handlers
. Это Map
, где ключами выступают оригинальные эффекты, а значениями — новые хэндлеры.
// обработчик этого эффекта делает запрос в интернет
const fetchDataFx = createEffect(async () => {
const { data } = await axios.get('some_url')
console.log('REAL')
return data
})
// чтобы заменить его на другой в конкретном скоупе,
// нужно передать новый обработчик при вызове fork
const scope = fork({
handlers: new Map([
[fetchDataFx, () => console.log('FAKED')]
])
})
// вызовы эффекта на скоупе будут писать в консоль
// вместо сетевого вызова
await allSettled(fetchDataFx, { scope }) // -> FAKED
// вызовы эффекта без скоупа будут
// делать запросы в интернет
await fetchDataFx() // -> REAL
fork({ values })
Аналогично можно подменить значение любого стора в конкретном скоупе. Для этого нужно передать в функцию fork
объект с полем values
. Это Map
, где ключами выступают оригинальные сторы, а значениями — новый значения для них.
const $data = createStore('REAL')
// чтобы заменить значение стора в конкретном скоупе,
// нужно передать новое значение при вызове fork
const scope = fork({
values: new Map([
[$data, 'FAKED']
])
})
// теперь в скоупе будет лежать новое значение
console.log(scope.getState($data)) // -> FAKED
// но в оригинальном сторе будет старое значение
console.log($data.getState()) // -> REAL
Кстати, не надо подменять значения производных сторов, полученных с помощью вызовов
.map
,combine
и других. Производные сторы вообще лучше не модифицировать.
fork({ values, handlers })
При вызове fork
можно передать одновременно и values
и handlers
.
Предосторожности при императивных вызовах
Есть несколько ситуаций, в которых Эффектор может потерять текущий скоуп. Все они связаны с императивными вызовами ивентов и эффектов.
- Вызов в одном обработчике эффекта других эффектов И обычных асинхронных функций. Документация. Вот примеры 👇
// 👍 так можно
// используются только обычные асинхронные функции
const fetchWithDelayFx = createEffect(async (url) => {
await new Promise(rs => setTimeout(rs, 80))
const { data } = await axios.get(url)
return data
})
// 👍 так можно
// используются только эффекты
const sendWithAuthFx = createEffect(async () => {
await authUserFx()
await delayFx()
await sendMessageFx()
})
// 👎 так не можно
// используются И эффекты, И обычные асинхронные функции
const sendWithAuthFx = app.createEffect(async () => {
await authUserFx()
await new Promise(rs => setTimeout(rs, 80))
await sendMessageFx()
})
- Вызов ивентов в не-эффектор контексте. Документация. Правильно делать вот так 👇
const installHistory = createEvent()
const changeLocation = createEvent()
installHistory.watch(history => {
// прикрепляем событие changeLocation к текущему скоупу
const locationUpdate = scopeBind(changeLocation)
history.listen(location => {
// теперь при вызове скоуп не потеряется
locationUpdate(location.pathname)
})
})
Особенности работы скоупов с React
Хорошие модульные тесты должны отдельно тестировать модели (юниты Эффектора и их связи) и отдельно отображение (компоненты и хуки Реакта). Но, мир не идеален, и иногда удобнее протестировать компонент с уже привязанной к нему логикой.
В этом случае внутри Реакта (в компонентах и кастомных хуках) нельзя вызывать Эффектор-ивенты напрямую, нужно привязывать их к скоупу через useEvent
. Для клиентского кода этот хук можно импортировать из effector-react
и он будет просто возвращать оригинальный ивент.
import { useStore, useEvent } from 'effector-react'
const inc = createEvent()
const $count = createStore(0)
.on(inc, x => x + 1)
const TestableComponent = () => {
const count = useStore($count)
// Прибиваем контекст для тестов
const clickHandler = useEvent(inc)
return (
<>
<p>Count: {count}</p>
{/* 👍 все правильно */}
<button onClick={clickHandler}>increment</button>
{/* 👎 так не сработает */}
<button onClick={inc}>increment</button>
</>
)
}
После этого, в тестах достаточно обернуть компонент в провайдер.
Оборачивать в провайдер тестируемый компонент лучше прямо в тест-кейсе:
import { render } from '@testing-library/react';
import { Provider } from 'effector-react'
describe('TestableComponent', () => {
test('should increment counter after click', () => {
const scope = fork()
const rendered = render(
<Provider value={scope}>
<TestableComponent />
</Provider>
);
// можно делать что угодно с компонентом
// и проверять что угодно в скоупе
})
})
Мне не нравится этот способ тестировать компоненты, но техническая возможность так делать есть. Иногда по-другому протестировать компонент невозможно.
allSettled()
Вторая часть хорошего теста — возможность убедиться, что все вычисления закончились и можно переходить к проверкам результатов работы теста. Для этого есть функция allSettled
, она делает сразу две вещи:
- вызывает ивент/эффект на конкретном скоупе;
- дожидается, пока закончатся все вычисления, спровоцированные вызовом.
Вторая особенность важна при тестировании сложных асинхронных пользовательских сценариев. Например, представьте себе, что приложение должно делать примерно такое:
// Логин пользователя вернет userId
const loginFx = createEffect(async () => {
const { data: userId } = await axios.post('...')
return userId
})
// Для пользователя можно получить текущую валюту по userId
const fetchCurrencyFx = createEffect(async (userId) => {
const { data: currency } = await axios.get('...')
return currency
})
// Сюда сохраним полученную валюту пользователя
const $userCurrency = createSotre(null)
// Пользоваетель нажимает на кнопку логин
const loginButtonClicked = createEvent()
// Когда
forward({
// пользователь кликнул на кнопку «логин»
from: loginButtonClicked,
// выполняем сайд-эффект логина
to: loginFx
})
// Когда
forward({ from:
// процесс логина успешно завершился, берем результат
loginFx.doneData,
// и вызываем запрос валюты с ним
to: fetchCurrencyFx
})
// Когда
forward({
// запрос валюты завершился, берем результат
from: fetchCurrencyFx.doneData,
// и кладем его в стор
to: $userCurrency
})
Это совсем небольшой сценарий, который имеет важную особенность. Снаружи не понятно, в каком порядке будут выполняться эффекты. Даже нет гарантии, что они будут асинхронными. Но благодаря allSettled
сценарий можно покрыть достаточно простым тестом.
describe('login flow', () => {
test('set currency after login', async () => {
const scope = fork({
// подменяем обработчики
// чтобы не делать настоящих запросов
handlers: new Map([
[loginFx, () => 'FAKE_ID'],
[fetchCurrencyFx, () => 'FAKE_CURRENCY']
])
})
// имитируем клик пользователя
// и дожидаемся, пока все ивенты и эффекты выполнятся
await allSettled(loginButtonClicked, { scope })
// проверяем, что в итоге валюта окажется корректной
expect(scope.getState($userCurrency)).toBe('FAKE_CURRENCY')
})
})
Подробнее про важность allSettled
я рассказывал в статье про лучшую часть Эффектора.
scope.getState()
Это просто получение значение стора в конкретном скоупе. Тут все просто, передаем стор в scope.getState()
, немедленно получаем его текущее значение. Значение проверяем привычными методами тест-раннера.
effector/babel-plugin
Последний важный ингредиент удобных тестов с Эффектором — это бабель-плагин. Он поставляется вместе с библиотекой и делает много рутинной работы. Документация.
При работе с Fork API
важно отличать юниты друг от друга, для этого внутри используются sid
-ы — уникальные имена юнитов. На самом деле, их можно проставлять руками при создании каждого юнита, но это неудобно. Лучше эту работу можно переложить на бабель-плагин.
// babel.config.js
module.exports = (api) => {
// Добавляем плагин только для тестового окружения
if (api.env('test')) {
config.plugins.push([
'effector/babel-plugin',
]);
}
return config;
};
На самом деле, этот плагин имеет смысл использовать во всех окружениях. Кроме проставления
sid
-ов он помогает утилитамиeffector-logger
,effector-inspector
иpatronum/debug
отображать осмысленные имена юнитов. Плюс, пригодится, если в приложении появится SSR.
Резюме
Тестирование Эффектор-модулей — это один из самых приятных видов тестирования приложения.
- Все нужные фичи (подмена значений и обработчиков,
allSettled
, независимость скоупов) доступны из коробки и поддерживаются как граждане первой категории. - Тесты не зависят от пользовательского интерфейса. Их проще писать, рефакторить и поддерживать, они стабильные и быстрые.
Пишите тесты, будьте счастливы 💙
Latest comments (2)
Всё-таки не всё понятно при работе с React Testing Library (RTL).
В RTL есть инструкция о том, как замокать раз и навсегда провайдеры, создавая
customerRender
функцию и передавая тудаAllTheProviders
.Сработает ли такой подход здесь?
Пример кода: stackoverflow.com/a/73150552/8237608
Дока RTL: testing-library.com/docs/react-tes...
Вы привели пример с
fork
иhandlers
, но я так понял, что это только для эффектов. А как мокатьevents
? Или триггеря черезallSetlled
event мы должны проверять потом не триггернулся ли замоканный эффект?Ок, я добавил провайдер с актуальным scope, добавил fork c handlers, но когда запускаю, то получаю "Cannot read properties of undefined (reading 'createStore')". Я так понимаю, что это из-за
domain
? Как и его замокать?Вот у вас есть пример с
loginButtonClicked
, можете добавить пример как проверить, что он отработал (expect(...).toHaveBeenCalledTimes(1), к примеру)?Разным тестам могут быть нужны разные моки, поэтому и Provider из
effector-react
с форкнутым скоупом у каждого теста скорее всего будет свой.Ивенты не имеют ни своего состояния, ни сайд-эффектов внутри себя - там нечего мокать.
Не очень понятна проблема без примера кода - похоже на проблемы со сборкой. Начиная с effector@22.0.0 домены не обязательны для тестов или SSR
Достаточно сделать watch на ивенте: