Если вы когда-либо писали тест, который проверяет, что 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 и начинают проверять состояние.