If you have ever written a test that verifies dispatch({ type: 'FETCH_USER_SUCCESS' }) was called with the exact payload shape, you have written a test that breaks every time someone renames a constant.
That is not testing your state logic. It is testing that your fingers typed the right strings.
Redux testing tutorials often start with Jest mocks: spy on dispatch, assert the action creator was called, assert the type matches, done. That works for a tutorial. It falls apart in a real codebase where action types change, payloads get refactored, and your tests become a to-do list of every dispatch call in the app.
The alternative is to test the store as a unit, not the action creators as individuals.
Why action-by-action testing collapses at scale
Imagine a user profile component that fetches data, handles loading states, and surfaces errors. The naive test looks like this:
// 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' },
});
});
This test passes. It also fails the moment someone adds a meta field to the success action, or wraps the payload in a data key, or switches to createAsyncThunk.
More importantly, it tells you nothing about whether the reducer actually did anything with that action. The action could fly into a reducer that ignores it and the test would still be green.
The integration test approach: treat the store as a black box
The store is already a pure function. Given a state and an action, it returns a new state. The most honest test is to feed it actions and inspect the state.
You do not need to mock the reducers. You do not need to mock the action creators. You set up a real store, dispatch real actions (or action creators), and read the resulting 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' });
});
This is one line of dispatch and one assertion. It tests the contract your UI actually depends on: that after something happens, the state looks a certain way.
Testing thunks and async logic with a real store
Testing async middleware is where most teams reach for mocks. You do not have to.
If you are using Redux Toolkit, configureStore already includes redux-thunk and the devtools middleware. A test store with the same middleware stack behaves identically to your 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();
// 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');
});
If fetchUser calls an API, you still need to handle the network layer. But that is a fetch problem, not a Redux problem. Use msw to intercept the HTTP request, or pass a custom API client through the thunk’s extraArgument. Either way, your assertions stay focused on state, not on which functions got called in what order.
When to mock, and when not to
I am not arguing for zero mocks. Mocks are appropriate at system boundaries: HTTP requests, browser APIs, timers, external libraries with side effects.
Reducers are not a system boundary. They are deterministic functions inside your own codebase. Mocking them is like mocking a filter call.
Action creators, especially thunks, are orchestration logic. You can test them through their effects on state instead of their internal dispatch calls. That removes the coupling between your tests and the shape of your actions.
The trade-off is speed. A test that exercises the full reducer stack and middleware is slower than a test that asserts on a mock. In practice, the difference is milliseconds. If your test suite is slow, the culprit is almost never Redux store tests. It is spinning up a JSDOM instance for every file, or calling a real database, or not cleaning up timers.
What if you are not using Redux Toolkit?
Plain Redux works the same way. Build a store with createStore, apply your middleware, and 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);
});
If you have a complex middleware chain (logging, analytics, sagas), include it in the test store or trim it. The goal is to test the behavior your components rely on, not to replicate production exactly.
Selector tests: the other half of the contract
Testing the store in isolation is only half the story. Your components read state through selectors. A selector test is equally simple and equally valuable.
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');
});
Notice the selector test does not touch a store at all. It takes a state shape and returns a derived value. This is what makes it fast and what makes it precise. The contract between your reducers and your selectors is the state shape, and both sides can be tested independently.
A pattern for larger codebases
In a codebase with many slices, creating a test store per test file gets repetitive. Centralize it.
// 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,
}),
});
}
Then import createTestStore in every test. Consistency makes it easier for teammates to write tests that behave the same way, and it gives you one place to tweak middleware or add test-specific reducers.
Where this stops working
There are cases where you want to test at the action level. If you have custom middleware that transforms actions, you need to assert on the transformed action. If you are building a library that exports action creators, the action shape is the public API.
For application code, though, the action is an implementation detail. The state is what matters.
FAQ
Should I test connected React components?
Test the component in isolation with mocked props, or test the integration with @testing-library/react and a real provider. Both are valid. The key is to not test the Redux plumbing through the component. Test what the user sees.
What about sagas?
Redux-Saga is a different beast because it manages its own control flow. You can test sagas by running them with redux-saga-test-plan and asserting on state changes, or you can test the saga in isolation with cloneableGenerator. Sagas are the one place where mocking effects is usually worth it.
How do I handle side effects like analytics?
Extract them into middleware and test the middleware separately, or use the extraArgument pattern to inject a mock analytics client into your thunks.
Does this work with Zustand or other state managers?
Yes. The principle is the same: initialize the store, trigger a state change, read the state. Zustand stores are even easier because they are often just functions.
Try it on one slice
Pick the slice you touch most often. Replace one mock-heavy test with a real store test. Run it. If it is slower by more than 10ms, I will buy you a coffee.
Most teams find their Redux tests become shorter, survive refactors, and actually catch bugs once they stop verifying dispatch calls and start verifying state.