Wenn du jemals einen Test geschrieben hast, der prüft, ob dispatch({ type: 'FETCH_USER_SUCCESS' }) mit der exakten Payload-Form aufgerufen wurde, hast du einen Test geschrieben, der jedes Mal kaputt geht, wenn jemand eine Konstante umbenennt.

Das testet nicht deine State-Logik. Es testet, dass deine Finger die richtigen Strings getippt haben.

Redux-Testing-Tutorials beginnen oft mit Jest-Mocks: Spione auf dispatch setzen, prüfen, ob der Action-Creator aufgerufen wurde, prüfen, ob der Typ übereinstimmt, fertig. Das funktioniert für ein Tutorial. Es bricht in einer echten Codebase zusammen, in der sich Action-Types ändern, Payloads refactored werden und deine Tests zu einer To-do-Liste für jeden Dispatch-Aufruf in der App werden.

Die Alternative ist, den Store als Einheit zu testen, nicht die Action-Creators einzeln.

Warum das Action-für-Action-Testen im großen Maßstab kollabiert

Stell dir eine User-Profile-Komponente vor, die Daten abruft, Loading-States behandelt und Fehler anzeigt. Der naive Test sieht so aus:

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

Dieser Test besteht. Er fällt aber durch, sobald jemand ein meta-Feld zur Success-Action hinzufügt, den Payload in einen data-Key wrappt oder zu createAsyncThunk wechselt.

Wichtiger noch: Er sagt dir nichts darüber, ob der Reducer tatsächlich etwas mit dieser Action gemacht hat. Die Action könnte in einen Reducer fliegen, der sie ignoriert, und der Test wäre trotzdem grün.

Der integration test-Ansatz: Den Store als Blackbox behandeln

Der Store ist bereits eine pure Funktion. Gegeben ein State und eine Action, gibt er einen neuen State zurück. Der ehrlichste Test ist, ihm Actions zu füttern und den State zu inspizieren.

Du musst die Reducers nicht mocken. Du musst die Action-Creators nicht mocken. Du richtest einen echten Store ein, dispatchest echte Actions (oder Action-Creators) und liest den resultierenden State.

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

Das ist eine Zeile Dispatch und eine Assertion. Es testet den Vertrag, von dem deine UI tatsächlich abhängt: dass nachdem etwas passiert ist, der State auf eine bestimmte Weise aussieht.

Thunks und Async-Logik mit einem echten Store testen

Async-Middleware zu testen, ist der Punkt, an dem die meisten Teams zu Mocks greifen. Das musst du nicht.

Wenn du Redux Toolkit verwendest, enthält configureStore bereits redux-thunk und die Devtools-Middleware. Ein Test-Store mit dem gleichen Middleware-Stack verhält sich identisch zu deinem Production-Store.

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

  // den Thunk dispatchen
  const promise = store.dispatch(fetchUser(42));

  // Loading-State sofort prüfen
  expect(store.getState().user.loading).toBe('pending');

  await promise;

  // finalen State prüfen
  expect(store.getState().user.loading).toBe('idle');
  expect(store.getState().user.entities[42].name).toBe('Ada');
});

Wenn fetchUser eine API aufruft, musst du trotzdem den Network-Layer handhaben. Aber das ist ein Fetch-Problem, kein Redux-Problem. Verwende msw, um den HTTP-Request abzufangen, oder übergib einen custom API-Client durch das extraArgument des Thunks. Auf jeden Fall bleiben deine Assertions auf den State fokussiert, nicht darauf, welche Funktionen in welcher Reihenfolge aufgerufen wurden.

Wann man mockt, und wann nicht

Ich plädiere nicht für null Mocks. Mocks sind an Systemgrenzen angemessen: HTTP-Requests, Browser-APIs, Timer, externe Libraries mit Side Effects.

Reducers sind keine Systemgrenze. Sie sind deterministische Funktionen in deiner eigenen Codebase. Sie zu mocken ist wie einen filter-Aufruf zu mocken.

Action-Creators, besonders Thunks, sind Orchestrierungslogik. Du kannst sie durch ihre Effekte auf den State testen, statt durch ihre internen Dispatch-Aufrufe. Das entfernt die Kopplung zwischen deinen Tests und der Form deiner Actions.

Der Kompromiss ist Geschwindigkeit. Ein Test, der den vollen Reducer-Stack und Middleware durchläuft, ist langsamer als ein Test, der auf einem Mock assertet. In der Praxis ist der Unterschied Millisekunden. Wenn deine Test-Suite langsam ist, ist der Übeltäter fast nie Redux-Store-Tests. Es ist das Hochfahren einer JSDOM-Instanz für jede Datei, das Aufrufen einer echten Datenbank oder das Nicht-Aufräumen von Timern.

Was, wenn du Redux Toolkit nicht verwendest?

Plain Redux funktioniert auf die gleiche Weise. Baue einen Store mit createStore, wende deine Middleware an und dispatche.

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

Wenn du eine komplexe Middleware-Chain hast (Logging, Analytics, Sagas), nimm sie in den Test-Store auf oder kürze sie. Das Ziel ist es, das Verhalten zu testen, auf das deine Komponenten angewiesen sind, nicht Production exakt zu replizieren.

Selector-Tests: die andere Hälfte des Vertrags

Den Store isoliert zu testen, ist nur die halbe Miete. Deine Komponenten lesen State durch Selectors. Ein Selector-Test ist gleichermaßen einfach und wertvoll.

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

Beachte, dass der Selector-Test überhaupt keinen Store berührt. Er nimmt eine State-Shape und gibt einen abgeleiteten Wert zurück. Das macht ihn schnell und präzise. Der Vertrag zwischen deinen Reducern und deinen Selectors ist die State-Shape, und beide Seiten können unabhängig getestet werden.

Ein Pattern für größere Codebases

In einer Codebase mit vielen Slices wird das Erstellen eines Test-Stores pro Testdatei repetitiv. Zentralisiere ihn.

// 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({
        // immutable- und serializable-Checks in Tests deaktivieren
        // um Noise beim Testen von Edge-Cases zu vermeiden
        immutableCheck: false,
        serializableCheck: false,
      }),
  });
}

Dann importiere createTestStore in jedem Test. Konsistenz macht es einfacher für Teammitglieder, Tests zu schreiben, die sich gleich verhalten, und gibt dir einen Ort, um Middleware anzupassen oder test-spezifische Reducer hinzuzufügen.

Wo das aufhört zu funktionieren

Es gibt Fälle, in denen du auf Action-Level testen willst. Wenn du eine Custom-Middleware hast, die Actions transformiert, musst du auf die transformierte Action asserten. Wenn du eine Library baust, die Action-Creators exportiert, ist die Action-Shape die public API.

Für Application-Code hingegen ist die Action ein Implementierungsdetail. Der State ist es, was zählt.

FAQ

Sollte ich connected React-Komponenten testen?

Teste die Komponente isoliert mit gemockten Props, oder teste die Integration mit @testing-library/react und einem echten Provider. Beides ist valide. Der Schlüssel ist, nicht das Redux-Plumbing durch die Komponente zu testen. Teste, was der User sieht.

Was ist mit Sagas?

Redux-Saga ist ein anderes Biest, weil es seinen eigenen Control-Flow verwaltet. Du kannst Sagas testen, indem du sie mit redux-saga-test-plan ausführst und auf State-Changes assertest, oder du kannst die Saga isoliert mit cloneableGenerator testen. Sagas sind der eine Ort, an dem das Mocken von Effects normalerweise wert ist.

Wie handle ich Side-Effects wie Analytics?

Extrahiere sie in Middleware und teste die Middleware separat, oder verwende das extraArgument-Pattern, um einen mock analytics client in deine Thunks zu injizieren.

Funktioniert das mit Zustand oder anderen State-Managern?

Ja. Das Prinzip ist das gleiche: Initialisiere den Store, triggere einen State-Change, lies den State. Zustand-Stores sind sogar noch einfacher, weil sie oft einfach nur Funktionen sind.

Probier es bei einem Slice aus

Nimm den Slice, den du am häufigsten anfasst. Ersetze einen mock-lastigen Test durch einen echten Store-Test. Führe ihn aus. Wenn er um mehr als 10ms langsamer ist, lade ich dich auf einen Kaffee ein.

Die meisten Teams stellen fest, dass ihre Redux-Tests kürzer werden, Refactors überleben und tatsächlich Bugs finden, sobald sie aufhören, Dispatch-Aufrufe zu verifizieren, und anfangen, den State zu verifizieren.