Si vous avez déjà écrit un test qui vérifie que dispatch({ type: 'FETCH_USER_SUCCESS' }) a été appelé avec la forme exacte du payload, vous avez écrit un test qui casse à chaque fois que quelqu’un renomme une constante.

Ce n’est pas tester votre logique d’état. C’est tester que vos doigts ont tapé les bonnes chaînes de caractères.

Les tutoriels de test Redux commencent souvent avec des mocks Jest : espionner dispatch, vérifier que le action creator a été appelé, vérifier que le type correspond, fini. Ça marche pour un tutoriel. Ça s’effondre dans une vraie codebase où les types d’actions changent, les payloads sont refactorés, et vos tests deviennent une liste de tâches de chaque appel dispatch dans l’app.

L’alternative est de tester le store comme une unité, pas les action creators comme des individus.

Pourquoi le test action par action s’effondre à l’échelle

Imaginez un composant de profil utilisateur qui récupère des données, gère les états de chargement, et remonte les erreurs. Le test naïf ressemble à ceci :

// 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' },
  });
});

Ce test passe. Il échoue aussi dès que quelqu’un ajoute un champ meta à l’action de succès, ou enveloppe le payload dans une clé data, ou passe à createAsyncThunk.

Plus important encore, il ne vous dit rien sur le fait que le reducer ait réellement fait quelque chose avec cette action. L’action pourrait atterrir dans un reducer qui l’ignore et le test serait toujours vert.

L’approche de test d’intégration : traiter le store comme une boîte noire

Le store est déjà une fonction pure. Étant donné un état et une action, il retourne un nouvel état. Le test le plus honnête est de lui donner des actions et d’inspecter l’état.

Vous n’avez pas besoin de mocker les reducers. Vous n’avez pas besoin de mocker les action creators. Vous configurez un vrai store, dispatchez de vraies actions (ou action creators), et lisez l’état résultant.

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' });
});

C’est une ligne de dispatch et une assertion. Il teste le contrat sur lequel votre UI dépend réellement : qu’après qu’il se passe quelque chose, l’état ait une certaine apparence.

Tester les thunks et la logique asynchrone avec un vrai store

Tester le middleware asynchrone est l’endroit où la plupart des équipes se tournent vers les mocks. Vous n’êtes pas obligé.

Si vous utilisez Redux Toolkit, configureStore inclut déjà redux-thunk et le middleware devtools. Un store de test avec la même pile de middleware se comporte de manière identique à votre store de production.

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');
});

Si fetchUser appelle une API, vous devez toujours gérer la couche réseau. Mais c’est un problème de fetch, pas un problème Redux. Utilisez msw pour intercepter la requête HTTP, ou passez un client API personnalisé via le extraArgument du thunk. Dans les deux cas, vos assertions restent centrées sur l’état, pas sur quelles fonctions ont été appelées dans quel ordre.

Quand mocker, et quand ne pas mocker

Je ne prône pas zéro mock. Les mocks sont appropriés aux frontières du système : requêtes HTTP, APIs du navigateur, timers, bibliothèques externes avec des effets de bord.

Les reducers ne sont pas une frontière du système. Ce sont des fonctions déterministes dans votre propre codebase. Les mocker, c’est comme mocker un appel à filter.

Les action creators, surtout les thunks, sont de la logique d’orchestration. Vous pouvez les tester à travers leurs effets sur l’état au lieu de leurs appels dispatch internes. Ça supprime le couplage entre vos tests et la forme de vos actions.

Le compromis est la vitesse. Un test qui exécute la pile complète de reducers et de middleware est plus lent qu’un test qui vérifie un mock. En pratique, la différence est de quelques millisecondes. Si votre suite de tests est lente, le coupable n’est presque jamais les tests de store Redux. C’est l’instanciation d’une instance JSDOM pour chaque fichier, ou l’appel à une vraie base de données, ou le fait de ne pas nettoyer les timers.

Et si vous n’utilisez pas Redux Toolkit ?

Le Redux vanilla fonctionne de la même manière. Construisez un store avec createStore, appliquez votre middleware, et dispatchez.

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);
});

Si vous avez une chaîne de middleware complexe (logging, analytics, sagas), incluez-la dans le store de test ou allégez-la. Le but est de tester le comportement sur lequel vos composants comptent, pas de répliquer exactement la production.

Tests de selectors : l’autre moitié du contrat

Tester le store en isolation n’est que la moitié de l’histoire. Vos composants lisent l’état via des selectors. Un test de selector est tout aussi simple et tout aussi précieux.

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');
});

Remarquez que le test de selector ne touche pas du tout à un store. Il prend une forme d’état et retourne une valeur dérivée. C’est ce qui le rend rapide et précis. Le contrat entre vos reducers et vos selectors est la forme de l’état, et les deux côtés peuvent être testés indépendamment.

Un pattern pour les codebases plus grandes

Dans une codebase avec de nombreux slices, créer un store de test par fichier de test devient répétitif. Centralisez-le.

// 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,
      }),
  });
}

Importez ensuite createTestStore dans chaque test. La cohérence rend plus facile pour les coéquipiers d’écrire des tests qui se comportent de la même manière, et cela vous donne un endroit unique pour ajuster le middleware ou ajouter des reducers spécifiques aux tests.

Où ça cesse de fonctionner

Il y a des cas où vous voulez tester au niveau de l’action. Si vous avez un middleware personnalisé qui transforme les actions, vous devez vérifier l’action transformée. Si vous construisez une bibliothèque qui exporte des action creators, la forme de l’action est l’API publique.

Pour le code applicatif, cependant, l’action est un détail d’implémentation. C’est l’état qui compte.

FAQ

Devrais-je tester les composants React connectés ?

Testez le composant en isolation avec des props mockées, ou testez l’intégration avec @testing-library/react et un vrai provider. Les deux sont valides. L’essentiel est de ne pas tester la plumbing Redux à travers le composant. Testez ce que l’utilisateur voit.

Et les sagas ?

Redux-Saga est une bête différente car elle gère son propre flux de contrôle. Vous pouvez tester les sagas en les exécutant avec redux-saga-test-plan et en vérifiant les changements d’état, ou vous pouvez tester la saga en isolation avec cloneableGenerator. Les sagas sont l’endroit unique où mocker les effects vaut généralement le coup.

Comment gérer les effets de bord comme l’analytics ?

Extrayez-les dans un middleware et testez le middleware séparément, ou utilisez le pattern extraArgument pour injecter un client analytics mocké dans vos thunks.

Est-ce que ça marche avec Zustand ou d’autres state managers ?

Oui. Le principe est le même : initialisez le store, déclenchez un changement d’état, lisez l’état. Les stores Zustand sont encore plus simples car ce sont souvent juste des fonctions.

Essayez sur un slice

Prenez le slice que vous touchez le plus souvent. Remplacez un test très chargé de mocks par un test avec un vrai store. Exécutez-le. S’il est plus lent de plus de 10 ms, je vous paie un café.

La plupart des équipes constatent que leurs tests Redux deviennent plus courts, survivent aux refactors, et détectent réellement des bugs une fois qu’elles arrêtent de vérifier les appels dispatch et commencent à vérifier l’état.