Testing Redux State
Redux at heart is a fundamentally simple concept. Itās somewhere to put your global application state and itās a mechanism to mutate that state. One aspect of Redux Iām particularly interested in is its testability - how to approach your testing and what to actually test. In this post Iāll dive into my preferences and the reasoning behind these choices.
On the surface testing Redux state is very simple. Letās quickly define a reducer, some actions and a selector function that I will use throughout:
export const INCREMENT = 'INCREMENT';
export const increment = c => ({ type: INCREMENT, payload: c });
export const DECREMENT = 'DECREMENT';
export const decrement = c => ({ type: DECREMENT, payload: c });
export const getCount = s => s.count;
const defaultState = { count: 0 };
const reducer = (state = defaultState, action) => {
switch(action.type) {
case INCREMENT:
return { ...state, count: state.count + action.payload };
case DECREMENT:
return { ...state, count: state.count - action.payload };
default:
return state;
}
}
export default reducer;
Nothing too strange there, just a reducer to track a count with actions to increment and decrement. Thereās also a selector function, getCount
. These functions are performing data retrieval, reading from the store and encapsulating exactly where on the store the data are. Typically your UI components will access state via the selectors, rather than reading from the state themselves. By doing so, in In larger Redux applications itās then possible to use libraries such as reselect to provide efficient combination and memoization of selectors, to reduce redraws.
Letās write a test for the increment behaviour!
import reducer, { getCount } from './state';
describe('counter', () => {
const state = reducer(undefined, { type: 'none' });
it('initially has a count of 0', () => {
expect(state.count).toEqual(0);
});
it('increments the counter', () = {
state.count = 3;
expect(state.count).toEqual(3);
});
it('retrieves count', () => {
state.count = 4;
expect(getCount(state)).toEqual(4);
});
});
These tests arenāt utilising the reducer in the way it does in production. The tests are directly manipulating and reading the state, whereas in production this will be handled through dispatching actions. Notice that thereās also a duplication: the selector test is doing exactly the same as the test thatās reading directly from the state. Iām not being too simplistic here, Iāve seen these kind of tests in real code. This typically happens when a developer wishes to demonstrate that theyāre practicing the craft of unit testing, albeit in the literal sense of unit testing everything. Whilst these tests will gain you some good code coverage metrics, in the long term they will hinder your project.
Refactoring the state will mean that your tests will fail. Youāll then have to update the tests. But wait, isnāt this a good thing? Red-green-refactor? Sure, it can help, but do you want to spend all of your time fixing failing tests? I want my tests to fail if Iāve broken a requirement or some sort of contract, rather than just rejigging the code around. Iād like for my tests to capture that the system is doing what ought to be doing, rather than testing that the itās doing what itās doing.
How can the tests be improved? Iād make my tests match how the code will be executed in reality. Dispatch actions to the reducer, donāt set the state directly. Use the selectors to read the state. Unit testing purists may be thinking that this is no longer a āunitā because itās not the smallest possible scope. Instead, Iād argue that itās the smallest logical scope for my tests.
import reducer, { getCount, increment } from './state';
describe('counter', () => {
let state = reducer(undefined, { type: 'none' });
it('initially has a count of 0', () => {
expect(getCount(state)).toEqual(0);
});
it('increments the counter', () = {
state. = reducer(state, increment(3));
expect(getCount(state)).toEqual(3);
});
});
When I refactor the state, if I donāt also update the selector, Iāve broken a contract and my test will fail. In the first example, test 3 would fail but test 2 would pass. Test 2 isnāt representative of reality, itās just noise that slows the developer down. I can refactor with more safety. Furthermore, my tests are now focusing on what weāre actually trying to capture and less that each moving part works in isolation.
Understandably, this is a fine line. To some degree itās playing with semantics and in such a small example it can feel marginal. However, consider a project with a larger state, complex interactions and a more involved workflow. A solid and representative approach to testing will help maintain test cases, making sure they will not hinder changing the code and act as sensible red flags for when functionality deviates from expected behaviour.