Redux reducers, actions, and selectors are pure functions, making them incredibly easy to test in isolation.
Let’s see how this plays out with a simple counter example.
// actions.js
export const INCREMENT = 'INCREMENT';
export const DECREMENT = 'DECREMENT';
export const increment = () => ({
type: INCREMENT,
});
export const decrement = () => ({
type: DECREMENT,
});
// reducers.js
import { INCREMENT, DECREMENT } from './actions';
const initialState = {
count: 0,
};
export const counterReducer = (state = initialState, action) => {
switch (action.type) {
case INCREMENT:
return { ...state, count: state.count + 1 };
case DECREMENT:
return { ...state, count: state.count - 1 };
default:
return state;
}
};
// selectors.js
export const selectCount = (state) => state.counter.count;
Testing Reducers
Reducers are the heart of state management in Redux. They take the current state and an action, and return a new state. Because they are pure functions (meaning, given the same input, they always produce the same output, and have no side effects), testing them is straightforward.
You want to verify two main things for each action type:
- Does the reducer correctly update the state when a known action is dispatched?
- Does the reducer return the existing state unchanged when an unknown action is dispatched?
// reducers.test.js
import { counterReducer } from './reducers';
import { increment, decrement } from './actions';
describe('counterReducer', () => {
const initialState = { count: 0 };
it('should return the initial state', () => {
expect(counterReducer(undefined, {})).toEqual(initialState);
});
it('should handle INCREMENT', () => {
const action = increment();
const expectedState = { count: 1 };
expect(counterReducer(initialState, action)).toEqual(expectedState);
});
it('should handle DECREMENT', () => {
const currentState = { count: 5 };
const action = decrement();
const expectedState = { count: 4 };
expect(counterReducer(currentState, action)).toEqual(expectedState);
});
it('should not change state for unknown actions', () => {
const currentState = { count: 10 };
const action = { type: 'UNKNOWN_ACTION' };
expect(counterReducer(currentState, action)).toEqual(currentState);
});
});
Testing Actions
Action creators are also pure functions. They simply return an action object. Testing them is about asserting that the correct action object, with the correct type and any necessary payload, is generated.
// actions.test.js
import { increment, decrement, INCREMENT, DECREMENT } from './actions';
describe('actions', () => {
it('should create an action to increment the count', () => {
const expectedAction = { type: INCREMENT };
expect(increment()).toEqual(expectedAction);
});
it('should create an action to decrement the count', () => {
const expectedAction = { type: DECREMENT };
expect(decrement()).toEqual(expectedAction);
});
});
Testing Selectors
Selectors are functions that extract specific pieces of data from the Redux state. They are crucial for decoupling your UI components from the exact shape of your state tree. Like reducers and actions, they are pure functions, making them trivial to test.
You want to ensure that a selector correctly retrieves the expected data from a given state shape.
// selectors.test.js
import { selectCount } from './selectors';
describe('selectors', () => {
it('should select the count from the state', () => {
// The state shape here assumes a root reducer that combines other reducers,
// and our counterReducer is mounted under the key 'counter'.
const mockState = {
counter: { count: 7 },
otherSlice: { data: 'something' },
};
expect(selectCount(mockState)).toBe(7);
});
it('should return undefined or a default if the slice is missing', () => {
const mockState = {
otherSlice: { data: 'something' },
};
// Depending on your reducer setup, this might return undefined or a default.
// Here, `state.counter` would be undefined, and accessing `.count` on undefined
// would throw. A more robust selector would handle this.
// For this simple example, we'll assume `state.counter` exists.
// A better selector would be:
// export const selectCount = (state) => state.counter?.count ?? 0;
// And then the test would be:
// expect(selectCount(mockState)).toBe(0);
// For the current selector:
// expect(() => selectCount(mockState)).toThrow(); // Or whatever the actual behavior is.
// Let's test a state where the slice exists but count is missing.
const mockStateWithSliceButNoCount = {
counter: {},
otherSlice: { data: 'something' },
};
expect(selectCount(mockStateWithSliceButNoCount)).toBeUndefined();
});
});
The beauty of Redux lies in this functional purity. By testing reducers, actions, and selectors in isolation, you build confidence that your state management logic is sound, and you can refactor your state shape or business logic with much less fear of introducing regressions.
The next step in testing a Redux application is usually integrating these units into more complex integration tests, often involving the Redux store itself, or testing how components interact with the Redux store via react-redux.