Если вы когда-либо писали тест, который проверяет, что dispatch({ type: 'FETCH_USER_SUCCESS' }) был вызван с точной структурой payload, вы написали тест, который ломается каждый раз, когда кто-то переименовывает константу.
Это не тестирование вашей логики состояния. Это тестирование того, что ваши пальцы набрали правильные строки.
Руководства по тестированию Redux часто начинаются с моков Jest: шпионить за dispatch, утверждать, что action creator был вызван, проверять совпадение типа, готово. Это работает для туториала. Но разваливается в реальной кодовой базе, где типы экшенов меняются, payload рефакторится, а ваши тесты превращаются в чек-лист каждого вызова dispatch в приложении.
Альтернатива — тестировать стор как единое целое, а не action creators по отдельности.
Почему тестирование экшен за экшеном разваливается в масштабе
Представьте компонент профиля пользователя, который загружает данные, обрабатывает состояния загрузки и показывает ошибки. Наивный тест выглядит так:
// The mock spiral
jest.mock('./api', () => ({ fetchUser: jest.fn() }));
it('dispatches fetch user action', async () => {
const dispatch = jest.fn();
await loadUser(42)(dispatch);
expect(dispatch).toHaveBeenCalledWith({
type: 'FETCH_USER_REQUEST',
});
expect(dispatch).toHaveBeenCalledWith({
type: 'FETCH_USER_SUCCESS',
payload: { id: 42, name: 'Ada' },
});
});
Этот тест проходит. Но он падает в тот момент, когда кто-то добавляет поле meta к успешному экшену, или заворачивает payload в ключ data, или переключается на createAsyncThunk.
Что важнее, он не говорит вам ничего о том, сделал ли reducer вообще что-нибудь с этим экшеном. Экшен мог бы попасть в reducer, который его игнорирует, и тест всё равно был бы зелёным.
Подход интеграционного тестирования: трактовать стор как чёрный ящик
Стор уже является чистой функцией. Дав ему состояние и экшен, он возвращает новое состояние. Самый честный тест — скормить ему экшены и проверить состояние.
Вам не нужно мокать reducers. Вам не нужно мокать action creators. Вы настраиваете реальный стор, диспатчите реальные экшены (или action creators) и читаете получившееся состояние.
import { configureStore } from '@reduxjs/toolkit';
import { userReducer } from './userSlice';
function createTestStore(preloadedState) {
return configureStore({
reducer: { user: userReducer },
preloadedState,
});
}
it('loads a user into state', () => {
const store = createTestStore();
store.dispatch({ type: 'user/fetchUser/fulfilled', payload: { id: 42, name: 'Ada' } });
expect(store.getState().user.entities[42]).toEqual({ id: 42, name: 'Ada' });
});
Это одна строка dispatch и одно утверждение. Он тестирует контракт, от которого реально зависит ваш UI: что после того, как что-то произошло, состояние выглядит определённым образом.
Тестирование thunks и асинхронной логики с реальным стором
Тестирование async middleware — это то место, где большинство команд тянутся к мокам. Вам не обязательно.
Если вы используете Redux Toolkit, configureStore уже включает redux-thunk и devtools middleware. Тестовый стор с тем же стеком middleware ведёт себя идентично вашему продакшен-стору.
import { configureStore } from '@reduxjs/toolkit';
import { userReducer, fetchUser } from './userSlice';
function createTestStore(preloadedState) {
return configureStore({
reducer: { user: userReducer },
preloadedState,
middleware: (getDefault) => getDefault(), // same thunk middleware
});
}
it('handles the full fetch lifecycle', async () => {
const store = createTestStore();
// dispatch the thunk
const promise = store.dispatch(fetchUser(42));
// check loading state immediately
expect(store.getState().user.loading).toBe('pending');
await promise;
// check final state
expect(store.getState().user.loading).toBe('idle');
expect(store.getState().user.entities[42].name).toBe('Ada');
});
Если fetchUser вызывает API, вам всё ещё нужно обрабатывать сетевой слой. Но это проблема fetch, а не Redux. Используйте msw, чтобы перехватывать HTTP-запрос, или передайте кастомный API-клиент через extraArgument thunk. В любом случае ваши утверждения остаются сфокусированными на состоянии, а не на том, какие функции были вызваны в каком порядке.
Когда мокать, а когда нет
Я не призываю к полному отказу от моков. Моки уместны на границах системы: HTTP-запросы, браузерные API, таймеры, внешние библиотеки с побочными эффектами.
Reducers не являются границей системы. Это детерминированные функции внутри вашей собственной кодовой базы. Мокать их — всё равно что мокать вызов filter.
Action creators, особенно thunks, — это оркестрационная логика. Вы можете тестировать её через эффекты на состоянии вместо внутренних вызовов dispatch. Это убирает связь между вашими тестами и формой ваших экшенов.
Компромисс — скорость. Тест, который прогоняет полный стек reducer’ов и middleware, медленнее теста, который утверждает мок. На практике разница — миллисекунды. Если ваш набор тестов медленный, виновник почти никогда не тесты Redux-стора. Это запуск JSDOM-инстанса для каждого файла, вызов реальной базы данных или отсутствие очистки таймеров.
Что если вы не используете Redux Toolkit?
Обычный Redux работает так же. Соберите стор с createStore, примените ваш middleware и dispatch.
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import { rootReducer } from './reducers';
function createTestStore(preloadedState) {
return createStore(rootReducer, preloadedState, applyMiddleware(thunk));
}
it('toggles the feature flag', () => {
const store = createTestStore({ features: { darkMode: false } });
store.dispatch({ type: 'features/TOGGLE_DARK_MODE' });
expect(store.getState().features.darkMode).toBe(true);
});
Если у вас сложная цепочка middleware (логирование, аналитика, sagas), включите её в тестовый стор или сократите. Цель — тестировать поведение, от которого зависят ваши компоненты, а не точно воспроизводить продакшен.
Тесты селекторов: вторая половина контракта
Тестирование стора изолированно — это только половина дела. Ваши компоненты читают состояние через селекторы. Тест селектора столь же прост и столь же ценен.
import { selectUserById, selectActiveUsers } from './userSelectors';
it('selects only active users', () => {
const state = {
user: {
entities: {
1: { id: 1, name: 'Ada', active: true },
2: { id: 2, name: 'Grace', active: false },
},
ids: [1, 2],
},
};
expect(selectActiveUsers(state)).toHaveLength(1);
expect(selectActiveUsers(state)[0].name).toBe('Ada');
});
Заметьте, тест селектора вообще не касается стора. Он берёт форму состояния и возвращает производное значение. Это то, что делает его быстрым и точным. Контракт между вашими reducers и селекторами — это форма состояния, и обе стороны могут тестироваться независимо.
Паттерн для больших кодовых баз
В кодовой базе со множеством slices создание тестового стора для каждого тестового файла становится утомительным. Централизуйте его.
// test-utils/store.js
import { configureStore } from '@reduxjs/toolkit';
import { rootReducer } from '../src/store';
export function createTestStore(preloadedState = {}) {
return configureStore({
reducer: rootReducer,
preloadedState,
middleware: (getDefault) =>
getDefault({
// disable immutable and serializable checks in tests
// to avoid noise when testing edge cases
immutableCheck: false,
serializableCheck: false,
}),
});
}
Затем импортируйте createTestStore в каждом тесте. Единообразие облегчает товарищам по команде писать тесты, которые ведут себя одинаково, и даёт вам одно место для подстройки middleware или добавления reducer’ов специфичных для тестов.
Где это перестаёт работать
Есть случаи, когда вы хотите тестировать на уровне экшена. Если у вас есть кастомный middleware, который трансформирует экшены, вам нужно утверждать преобразованный экшен. Если вы строите библиотеку, которая экспортирует action creators, форма экшена является публичным API.
Для прикладного кода, однако, экшен — это деталь реализации. Состояние — это то, что имеет значение.
FAQ
Нужно ли тестировать connected React-компоненты?
Тестируйте компонент изолированно с замоканными пропсами, или тестируйте интеграцию с @testing-library/react и реальным провайдером. Оба подхода валидны. Ключевое — не тестировать Redux-инфраструктуру через компонент. Тестируйте то, что видит пользователь.
Что насчёт sagas?
Redux-Saga — другое дело, потому что она управляет собственным потоком управления. Вы можете тестировать sagas, запуская их с redux-saga-test-plan и утверждая изменения состояния, или тестировать сагу изолированно с cloneableGenerator. Sagas — это единственное место, где мокание эффектов обычно окупается.
Как мне обрабатывать побочные эффекты вроде аналитики?
Вынесите их в middleware и тестируйте middleware отдельно, или используйте паттерн extraArgument, чтобы инжектировать мок-клиент аналитики в ваши thunks.
Работает ли это с Zustand или другими state-менеджерами?
Да. Принцип тот же: инициализируйте стор, вызовите изменение состояния, прочитайте состояние. Zustand-сторы ещё проще, потому что они часто являются просто функциями.
Попробуйте на одном slice
Возьмите slice, к которому вы прикасаетесь чаще всего. Замените один тест, перегруженный моками, тестом с реальным стором. Запустите его. Если он медленнее более чем на 10 мс, я угощу вас кофе.
Большинство команд находит, что их Redux-тесты становятся короче, переживают рефакторинги и реально ловят баги, как только перестают проверять вызовы dispatch и начинают проверять состояние.