Reducers are pure functions, so testing them is fairly straight forward, just input existing state and action, and check the returned state.
However, as more state is added to the store, the standard test format Redux - Writing Tests leads to a lot of verbose boiler plate and tests which are brittle when refactoring state.
The effort in testing reducers can be dramatically reduced by adopting certain conventions:
-
The action payload should have the same shape / properties as the state slice being updated.
-
Reducer code should be dumb. Any logic in the reducer (translating property name, deriving property values) should be moved to the action.
-
Actions should have two methods - one that creates the action (for use in tests), and another that dispatches the action (called from application code).
createChangeFile(fileInfo: IFileInfo): ActionType { // test this return { type: PageActions.CHANGE_FILE, payload: { ... }, }; } changeFile(fileInfo: IFileInfo) { // call this from components this.ngRedux.dispatch( this.createChangeFile(fileInfo) ); }
-
Reducer and action creator should be tested together. Steps are
- set up prior state
- call an action creator
- pass state and action to the reducer
- test that the new state is as expected
With the convention of 'dumb reducer', the only work required in the reducer function is the assigment of action.payload properties to corresponding state properties.
export function genericActionHandler(state, action) {
if (!action.payload) {
return state;
}
let newState = {...state};
const updated = Object.assign(newState, action.payload);
return updated;
}
Tests are data driven. The configuration object has action
and state
properties, equating to the reducer parameters and optionally payloadExpectedShape
.
export interface ReducerTestConfig {
action: object;
stateForReducer: object;
payloadExpectedShape?: object;
}
Actions are generated by action creators, which reduces brittleness when refactoring the actions.
The test suite for searchReducer shows a typical test when the reducer only uses the generic method.
describe('searchReducer', () => {
const mockNgReduxDispatcher = jasmine.createSpyObj('mockNgRedux', ['dispatch', 'getState']);
const actions = new SearchActions(mockNgReduxDispatcher);
const stateForResetTest = { page: 'aPage', pageIsSearchable: true, searchTerm: 'searchForThis', results: ['result1', 'result2']};
const tests = [
{
action: actions.createSetPage('aPage', true),
state: searchInitialState
},
{
action: actions.createSetSearchTerm('searchForThis'),
state: searchInitialState
},
{
action: actions.createSetResultsSuccess(['result1', 'result2']),
state: searchInitialState
},
{
action: actions.createSetResultsFailed(),
state: searchInitialState
},
{
action: actions.createResetResults(),
state: stateForResetTest
}
];
runAllReducerTests(searchReducer, tests);
});
The runAllReducerTests
function applies a generic set of tests to each action/reducer/state passed in.
These tests include the standard before/after state test performed in a standard reducer test, but can be performed generically because of the convention that payload == state.
In addition, it will run
- test for unknown action (NOP action)
- tests for immutability
- sub-state invariance (see below)
- payload shape tests
export function runReducerTests(state, reducer, action, expectedShape= null) {
describe(action.type, () => {
it(`should return state unchanged for nop action`, () => {
...
});
it(`should add payload to state`, () => {
...
});
it(`should clone the state`, () => {
...
});
it(`should not affect other sub state`, () => {
...
});
it('should have payload in expected shape', () => {
...
});
});
}
Some additional functionality comes into play in this application's page-reducer.
Since page-reducer's code is common to the validations, referentials, and clinics pages, we pass it the state slice below and the action contains the page name to be changed in action.subState.
Only that sub-state of the reducer's state slice is updated.
export interface IPageStateRoot {
validations?: IPageState;
referentials?: IPageState;
clinics?: IPageState;
}
Therefore, in the test spec an additional check ensures that other page data remains unchanged, and are in fact the same object references.
function check_SubState_OtherStateIsUnchanged(inputState, afterState, action) {
if (!action.subState) {
return;
}
Object.keys(inputState)
// tslint:disable-next-line:triple-equals
.filter(key => key != action.subState)
.forEach(key => {
expect(afterState[key]).toBe(inputState[key]);
});
}
One remaining source of bugs not covered by the test is invalid properties in the action payload properties. If the payload added by the action creator is not in the same shape as the expected state shape, the test will not pick up this error.
To avoid this, we define types for each state slice and use them in both the state definition and in action creators.
In the State definition:
export type PageState = {
files?: IFileInfo[],
fileInfo?: IFileInfo,
lastRefresh?: string,
fileCount?: number,
numVisible?: number,
visibleFiles?: IFileInfo[],
error?: any
}
export type PageStateRoot = {
validations?: PageState;
referentials?: PageState;
clinics?: PageState;
}
export interface IAppState {
pages?: PageStateRoot;
...
}
In the Action Type definition:
export type PageActionType = {
type: string,
payload?: PageState, // Constrain the payload properties to match state
...
}
In the Action Creators:
createChangeFile(fileInfo: IFileInfo): PageActionType {
return {
type: PageActions.CHANGE_FILE,
subState: this.PAGE,
payload: {
fileInfo,
unknownProp: 'not valid' // Compile time error
},
};
}