Testing redux reducers - Story Tests
Pete Hodgson
Posted on August 16, 2019
This post is part of a series, covering techniques for testing redux reducers.
In the first post of this series we discovered that it's better to test reducers and actions together, as an integrated, cohesive unit - the duck. In the next part of the series we pulled selectors into the scope of that integrated unit. We also looked at how we can simulate different scenarios in a selector test by using a reducer to get our redux state into the appropriate shape. However, the way we performed that set up was a little clunky.
In this post we'll explore cleaner, more expressive technique for creating this valuable type of reducer unit test - ones which validate the high-level behavior of a reducer as it responds to a series of actions, by moving a store's through a corresponding sequence of state transitions.)
Testing stateful behavior
We'll return to the same todo list reducer that we've been working with throughout this series. We’re going to verify that this reducer supports adding duplicate todo items with identical text. Here’s a first pass at testing this behavior:
import {getTodoCounts} from './selectors';
import reducer, {addTodo} from './duck';
describe('todos duck', () => {
it('supports multiple identical items', () => {
let state = undefined;
state = reducer(
state,
addTodo('identical todo')
);
expect(getTodoCounts(state).total).toEqual(1);
state = reducer(
state,
addTodo('identical todo')
);
expect(getTodoCounts(state).total).toEqual(2);
});
});
We start with an initial state (conventionally represented in redux as undefined
). We then use the reducer to apply an addTodo
action to that initial state, generating a new, second state containing one item. We confirm that this second state looks correct (leveraging selectors, as discussed previously). We then we take that second state and use the reducer again to apply a second addTodo
action. This generates a third state which should contain two identical items, which we then verify.
I call these types of stateful unit tests Story Tests. Each test tells the story of a series of actions occurring over time, describing how those actions change the store's state as they are processed by a reducer. I've found Story Tests to be a really concise, expressive way to validate the core behavior of a reducer via its public API 1.
You may have noticed that our test above doesn't follow the Arrange/Act/Assert structure that's often recommended for unit tests. Instead of a test invoking a single action and then asserting what happened, a Story Test invokes a series of actions, with assertions interspersed between these invocations. This violates a unit testing rule of thumb which some might consider sacrosanct - each unit test should only assert one thing. However, I have found that this is a case where it's OK to violate that guidance. When used appropriately, this style of test provides a very nice additional tool in my redux testing toolbelt, particularly handy when validating stateful behavior - as we often find ourselves wanting to do when testing reducers.
Streamlining our Stories
While I like this testing approach, I'm not happy with the manual juggling of states that we're currently having to do in that example test. That single state
variable which we're repeatedly mutating through the course of the test feels a little clunky. Let's see if we can do better.
If we take a step back and squint a little, what we’re really doing in that test is re-implementing a redux store, repeatedly hand-cranking that state variable through our reducer as we use it to apply our sequence of actions. What would it look like if we embraced this similarity and replaced our hand-cranked impression of a store with an actual redux store?
import {createStore} from 'redux';
import {getTodoCounts} from './selectors';
import reducer, {addTodo} from './duck';
describe('todos duck', () => {
it('supports multiple identical items', () => {
const store = createStore(reducer);
store.dispatch(addTodo('identical todo'));
expect(getTodoCounts(store.getState()).total).toEqual(1);
store.dispatch(addTodo('identical todo'));
expect(getTodoCounts(store.getState()).total).toEqual(2);
});
});
That's much better! We've removed a bunch of noise, boiling our test down to just showing the sequence of actions and the corresponding state expectations.
Note that this is different for alternative testing approaches using something like redux-mock-store. We’re using a standard redux store - not a test double of some kind - but it's acting as a sort of test harness - an isolated environment which we can use to validate the unit that we’re testing in a fairly integrated way, while still keeping our tests deterministic and fast.
This is my preferred approach to writing Story Tests for reducers. Each test sets a reducer up within a fresh redux store, then dispatches a series of actions into the store, using getState
to verify the state transitions the reducer is making along the way.
Telling a longer Story
As I mentioned above, I've found Story Tests to be an expressive way to describe the state transitions that a series of actions will move our store through. However our Story Test example so far is still pretty anemic, involving just a couple of actions. Here's a sketched-out example which gives an idea of what a longer Story Test for our todo reducer might look like:
import {createStore} from 'redux';
import {getTodoCounts,isTodoCompleted} from './selectors';
import reducer, {addTodo,completeTodo,deleteCompletedTodos} from './duck';
describe('todos duck', () => {
it('marks a todo as completed, then deletes the completed todo', () => {
const store = createStore(reducer);
store.dispatch(addTodo({text:'todo a', id:'todo-a'}));
store.dispatch(addTodo({text:'todo b', id:'todo-b'}));
expect(getTodoCounts(store.getState())).toEqual({
total: 2,
complete: 0,
incomplete: 2
});
store.dispatch(completeTodo({id: 'todo-a'}));
expect(getTodoCounts(store.getState())).toEqual({
total: 2,
complete: 1,
incomplete: 1
});
expect(isTodoCompleted(store.getState(),'todo-a')).toBe(true);
store.dispatch(deleteCompletedTodos());
expect(getTodoCounts(store.getState())).toEqual({
total: 1,
complete: 0,
incomplete: 1
});
});
});
Hopefully this gives a taste of the type of expressive reducer tests you can write with the Story Test approach. These are fast, decoupled unit tests which allow you to validate how our actions and reducers work together as an integrated unit, running inside a redux store just as they would in real application code.
I encourage you to give Story Tests a try yourself, and let me know how they work out!
-
Previously in this series I showed that action creators and selectors make up the public API for a "superduck" - a module encapsulating a reducer that manages a bucket of state, the actions that modify that state, and the selectors which query that state. ↩
Posted on August 16, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.