Jan Hesters

Unit Testing Reducers

Pure functions are simple and so should be their tests (when using the correct techniques). Reducers, action creators (which aren't thunks) and selectors are pure functions. After you read this article, when you apply these techniques, you'll simplify your tests and improve your code.

The tests in the examples are written with RITEway because it has a genius API that forces you to write good tests. RITEway only exposes an equality assertion, which is perfect for testing pure functions.

I'll let you read the code now first, and afterwards we analyse the structure of the tests.

import { combineReducers } from 'redux';
import { describe } from 'riteway';
 
const slice = 'count';
const initial = {
  count: 0,
};
 
const incrementBy = payload => ({ type: incrementBy.type, payload });
incrementBy.type = `${slice}/incrementBy`;
 
const reducer = (state = initial, { type, payload } = {}) => {
  switch (type) {
    case incrementBy.type:
      return { ...state, count: state.count + payload };
    default:
      return state;
  }
};
 
const getCount = ({ [slice]: { count } }) => count;
 
const rootReducer = combineReducers({ [slice]: reducer });
 
const createState = ({ count = 0 } = {}) => ({ count });
 
describe('counter reducer', async assert => {
  assert({
    given: 'no arguments',
    should: 'return the valid initial state',
    actual: reducer(),
    expected: createState(),
  });
 
  {
    const summand = 5;
 
    assert({
      given: 'no state and an increment by action',
      should: 'increment the count by the payload',
      actual: reducer(undefined, incrementBy(summand)),
      expected: createState({ count: summand }),
    });
  }
 
  {
    const count = 41;
    const summand = 1;
    const state = createState({ count });
 
    assert({
      given: 'state with a count and an increment by action',
      should: 'increment the count by the payload',
      actual: reducer(state, incrementBy(summand)),
      expected: createState({ count: 42 }),
    });
  }
 
  assert({
    given: 'default state and a get count selector',
    should: 'return 0',
    actual: getCount(rootReducer()),
    expected: 0,
  });
 
  {
    const summand = 42;
    const state = rootReducer(undefined, incrementBy(summand));
 
    assert({
      given: 'state with a count and a get count selector',
      should: 'return the current count',
      actual: getCount(state),
      expected: summand,
    });
  }
});

In the example above, we combined the logic and the tests. Usually, the code would live in at least three files (counter-reducer.js, counter-reducer.test.js and root-reducer.js):

src/
├── ...
├── features/
│   ├── ...
│   └── counter/
│       ├── ...
│       └── counter-reducer.js
│       └── counter-reducer.test.js
├── redux/
│   ├── root-reducer.js
│   ├── root-saga.js
│   └── store.js
└── index.test.js

The first test ensures the reducer has the correct initial state. Use a createState factory function for this test and avoid exporting the initial state and using it for the assertion.

// 👇 Avoid exporting your initial state 🚫
export const initialState = {
  wrongKey: 'foo',
  correctKey: 'wrong value'
  /* missing keys ... */
};
 
assert({
  given: 'no arguments',
  should: 'return the valid initial state',
  actual: reducer(),
  expected: initialState, // 😣 Missed errors 👎
});

You need to ensure the initial state is correct before you can use it in your tests. If you export it and use it in your assertion, an error might slip through. The initial state could have wrong keys, or the wrong initial values for correct keys or be missing keys and your tests would still pass.

The first test tests the reducer without arguments. A reducer that returns its initial state when called without arguments is not in Redux' official reducer specification. However, it's helpful for selector tests (- we will see below exactly how -) or to populate initial state for the useReducer Hook.

const [state, dispatch] = useReducer(reducer, reducer());

Suppose you can't call your reducers without arguments (for example because you're using Redux Toolkit). In that case, you can write this test by passing undefined and an empty object.

assert({
  given: 'no state with an empty action',
  should: 'return the valid initial state',
  actual: reducer(undefined, {}),
  expected: createState(),
});

Note: If you are looking for an alternative to Redux Toolkit that supports empty argument reducers, check out Autodux.

Test the action creators tightly coupled to your reducer by invoking both in your tests. Avoid action creator tests that assert on their returned action object because all you care about is how they modify the state of your reducer. Using action creators and their handlers together is the most efficient and effective way to test action creators and reducer functions.

Use a factory function like createState to set up the tests for action creators. (For selectors, directly use the rootReducer in conjunction with your actions to compose the state.) You can easily debug the factory function, which allows you to describe the exact state shape that you want. Favor a factory function over inlining the reducer's complete state because it's shorter to overwrite the relevant keys. Avoid using actions creators in the setup for your action creator tests because you don't know whether they behave correctly at that point. Sometimes (e.g. for complex reducers with many keys) you should additionally use a createPopulatedState factory function.

const createPopulatedState = ({
  count: 14
  /* , ... more populated values for more keys */
} = {}) => createState({ count /* , ... more keys */ });

Furthermore, notice how the action creators are tightly coupled to their actions and the case statements, so there is no need to worry about importing or using the right constant. If you have an action creator, you have its type (via incrementBy.type, or incrementBy().type - the former is compatible with thunks).

You should always write at least two tests for each action creator. One to test how it affects the reducer with no prior state (= initial state), and one to test how it interacts with existing state (of the same slice, obviously). (Sometimes you need more than one test for the "with state" scenario.)

Using createPopulatedState you can simplify your "with existing state" action creator test cases.

{
  const summand = 1;
  const state = createPopulatedState();
 
  assert({
    given: 'state with a count and an increment by action',
    should: 'increment the count by the payload',
    actual: reducer(state, incrementBy(summand)),
    expected: createPopulatedState({ count: 42 /* , ... rest untouched */ }),
  });
}

And best of all, if your action creator tests pass, you know that they change the state the way you intended. At this point, it's okay to use them in your selector tests to set up state. You also want to test the selectors with your rootReducer. (For local state with useReducer, you replace rootReducer with the reducer you're testing.) Using selectors in conjunction with action creators and your rootReducer gives you confidence that:

  1. You hooked up your reducer to your rootReducer, and you did it correctly.
  2. Your action creators work when interacting with each other (- if you use several to set up the test).
  3. Your selectors return the correct value.

You should test selectors with at least two cases, too: One with default state and one with the state you desire. For the default state, you can call the rootReducer without arguments to get the global initial state. And again, if you can't call your rootReducer without arguments to produce the default state (for example because you're using Redux Persist), you can create and export the rootState from your root-reducer.js file.

// root-reducer.js
const rootReducer = combineReducers({ /* ... */ });
 
const rootState = rootReducer(undefined, {});
 
export { rootReducer, rootState };
 
// in counter-reducer.test.js
import { rootReducer, rootState } from 'redux/root-reducer.js';
import { setOffset } from 'features/offset/offset-reducer.js';
 
// ...
 
assert({
  given: 'default state and a get count selector',
  should: 'return 0',
  actual: getCount(rootState),
  expected: 0,
});
 
{
  const actions = [incrementBy(10), setOffset(10), incrementBy(12)];
  const state = actions.reduce(rootReducer, rootState);
 
  assert({
    given: 'state with a count, an offset and a get count selector',

To test the selectors behavior with state, you can create an actions array and reduce over it to build up the state. This is an excellent technique because it works well even if your reducer depends on several pieces of state controlled by multiple actions (and slices e.g. setOffset).

Tips for TypeScript

You can create the state of a specific slice using the ReturnType type.

export type CountState = ReturnType<typeof reducer>;

I tend to create a general StateFactory type - usually inside of src/utils/types.ts, with which I can create the types for factory functions such as createState and createPopulatedState.

// ... in a general types file
export type StateFactory<T> = (state?: Partial<T>) => T;
 
// ... inside of the tests
import {
  CountState,
  reducer,
  // ... more imports
} from './count-reducer';
 
type CountStateFactory = StateFactory<CountState>;
 
const createState: CountStateFactory = ({ /* ... */ }) => ({ /* ... */ });
const createPopulatedState: CountStateFactory = ({ /* ... */ }) => ({ /* ... */ });

Learn senior fullstack secrets

Subscribe to my newsletter for weekly updates on new videos, articles, and courses. You'll also get exclusive bonus content and discounts.