Next in this series, we’re going to learn how to test Reducers in NGRX Store. In the previous testing post we explored Testing Actions.
As we know, Reducers are just pure functions. This makes it extremely easy to test your reducers - which control state changes - and respond to actions accordingly.
Table of contents
Another reason to adopt the Redux pattern with Angular is to enable step by step predictability and recording of all state object changes through immutable update patterns.
Reducers play a few key roles for us:
- Accept old state, and an action
- Respond to actions and compose/return new state
- Handle changes via immutable patterns
With this in mind, let’s jump into testing.
Testing Reducers
The way we think about testing reducers is actually to first consider the actions we are dispatching. Our thought process is “When I dispatch XYZ action, I expect my reducer to return me state that looks like ABC”. We pass something in, we get something new out. And this is just behaviour of pure functions.
What we’ll test
In this scenario, we’re going to test load a collection of pizzas. It nicely demonstrates a lot of the core concepts you’ll need.
// pizzas.action.ts
export const LOAD_PIZZAS = '[Products] Load Pizzas';
export const LOAD_PIZZAS_FAIL = '[Products] Load Pizzas Fail';
export const LOAD_PIZZAS_SUCCESS = '[Products] Load Pizzas Success';
export class LoadPizzas implements Action {
readonly type = LOAD_PIZZAS;
}
export class LoadPizzasFail implements Action {
readonly type = LOAD_PIZZAS_FAIL;
constructor(public payload: any) {}
}
export class LoadPizzasSuccess implements Action {
readonly type = LOAD_PIZZAS_SUCCESS;
constructor(public payload: Pizza[]) {}
}
To go with it, my reducer - which uses an entity pattern to flatten my data structure into object keys for performance:
// pizzas.reducer.ts
export interface PizzaState {
entities: { [id: number]: Pizza };
loaded: boolean;
loading: boolean;
}
export const initialState: PizzaState = {
entities: {},
loaded: false,
loading: false,
};
export function reducer(
state = initialState,
action: fromPizzas.PizzasAction
): PizzaState {
switch (action.type) {
case fromPizzas.LOAD_PIZZAS: {
return {
...state,
loading: true,
};
}
case fromPizzas.LOAD_PIZZAS_SUCCESS: {
const pizzas = action.payload;
const entities = pizzas.reduce(
(entities: { [id: number]: Pizza }, pizza: Pizza) => {
return {
...entities,
[pizza.id]: pizza,
};
},
{
...state.entities,
}
);
return {
...state,
loading: false,
loaded: true,
entities,
};
}
case fromPizzas.LOAD_PIZZAS_FAIL: {
return {
...state,
loading: false,
loaded: false,
};
}
}
return state;
}
The thing I love about using reducers is the absolute guarantee of sensible state changes. For smaller applications I’d even adopt the Redux pattern because it’s more about the thinking than the technology. Clarity trumps random updates across services/components for me.
Free eBook
Directives, simple right? Wrong! On the outside they look simple, but even skilled Angular devs haven’t grasped every concept in this eBook.
- Observables and Async Pipe
- Identity Checking and Performance
- Web Components <ng-template> syntax
- <ng-container> and Observable Composition
- Advanced Rendering Patterns
- Setters and Getters for Styles and Class Bindings
The whole reason we should test our reducers is to verify our state changes simply do their job. Once they work, they’ll work forever, and we can simply request the data we’ve added to the Store via selectors - which we’ll test next in this series.
Spec File
Because we’re testing Action -> Reducer -> New State, this is how we want to think when we test. Before we dive in though, we should always create an initial reducer test that checks that in the absense of an action the initialState
we create is always returned to the store.
This is for reasons such as initialisation of the store, when our reducer supplies that initial state, as well as if any actions are dispatched that don’t even exist. This second use case is likely something we aren’t worrying too much about however, as we’re testing right ;)
Let’s setup the spec file, I’m using barrels (index.ts
) to push everything in subdirectories upwards. This makes testing far easier, and we also have a single variable to reference things from:
import * as fromPizzas from './pizzas.reducer';
import * as fromActions from '../actions/pizzas.action';
import { Pizza } from '../../models/pizza.model';
describe('PizzasReducer', () => {
describe('undefined action', () => {
it('should return the default state', () => {
const { initialState } = fromPizzas;
const action = {};
const state = fromPizzas.reducer(undefined, action);
expect(state).toBe(initialState);
});
});
// I nest all tests under the reducer's name
// for readability in the terminal
});
Above, we destructure that initialState
property from the fromPizzas
import, which gives us this:
export const initialState: PizzaState = {
entities: {},
loaded: false,
loading: false,
};
This means we’re testing against the real initialState
object in our applications as well.
We also have const action = {}
which creates an object that we’re using to fake a dispatch. Anytime we dispatch for real, the store invokes the reducer. Here in the tests it’s our responsibility to invoke the reducers and test their output.
The magic is happening where we create const state
and invoke our reducer function. We pass in undefined, because we want to test zero state, and also a totally blank action.
The reason this returns new state is because of this guy at the end of our reducer:
export function reducer(
state = initialState,
action: fromPizzas.PizzasAction
): PizzaState {
switch (action.type) {
case fromPizzas.LOAD_PIZZAS: {...}
case fromPizzas.LOAD_PIZZAS_SUCCESS: {...}
case fromPizzas.LOAD_PIZZAS_FAIL: {...}
}
// I'm outside the switch case
// and I am here to save the day...
return state;
}
You could totally add a default
case to the switch, but honestly I prefer this way as it’s avoiding the switch altogether and I can just leave the switch to handle my actions. That’s my preference anyway, and you can adopt either.
Assertions
The test is evaluated through nothing more than our friend expect()
. Notice how we’re building a complex Angular application, yet have to setup nothing Angular related? Looking at you, TestBed, if you’re awake.
The final line of our test looks like this:
const state = fromPizzas.reducer(undefined, action);
expect(state).toBe(initialState);
So what’s happening here? Here’s the flow of what’s happened if you’ve not caught onto it just yet:
- We fake dispatch an action (call our reducer with some state and an action we’d like to test)
- We bind the result to
state
and check a property on that returned object
In our case, we’re testing the entire object - not just a property. When we dig a little further in a minute we’ll be testing individual properties but for initial state we can import our initialState
object and just make sure that actually works! And yes, it certainly does.
Here’s how we can think about the above test:
- Here’s my initialState.
- If I pass it into my reducer and we have no action, does it give it to me back?
- Yes it does, here you are! Green lights fill the room and some confetti comes down.
So let’s look at our first real test case, LOAD_PIZZAS
:
switch (action.type) {
case fromPizzas.LOAD_PIZZAS: {
return {
...state,
loading: true,
};
}
}
This state change awaits the action, and simply changes loading
to true
. That’d be a nice easy test to write:
describe('LOAD_PIZZAS action', () => {
it('should set loading to true', () => {
const { initialState } = fromPizzas;
const action = new fromActions.LoadPizzas();
const state = fromPizzas.reducer(initialState, action);
expect(state.loading).toEqual(true);
// untouched props, good to add regardless
expect(state.loaded).toEqual(false);
expect(state.entities).toEqual({});
});
});
The difference in the test above from the empty action test, is we’re actually creating an instance of the action class, and then passing that instance into the reducer - just like our store does for us. At this point we’re also passing in the initialState
property as the first argument to the reducer. This gets passed through as state
to our function and the action takes care of the rest.
When it also comes to mocking out state that we might want to test - this is the place we want to do that.
We’re then testing those individual properties on the state slice to ensure that only loading
has changed from false
to true
and the remaining props are untouched.
Before we move onto testing the success, let’s test the fail. It’s nice and simple and essentially just a reset:
switch (action.type) {
case fromPizzas.LOAD_PIZZAS_FAIL: {
return {
...state,
loading: false,
loaded: false,
};
}
}
We’re not loading
anymore, and we’ve definitely not loaded
- both are reverted to false regardless of their current state - which would likely be loading: true
beforehand.
Let’s add the test:
describe('LOAD_PIZZAS action', () => {
it('should return the previous state', () => {
const { initialState } = fromPizzas;
const previousState = { ...initialState, loading: true };
const action = new fromActions.LoadPizzasFail({});
const state = fromPizzas.reducer(previousState, action);
expect(state).toEqual(initialState);
});
});
Okay some new ideas here. First, I’m taking that initialState
and changing it before running the rest of the test. This is simply setting loading
to true, and I’m expecting my reducer to flip it back to false once the LoadPizzasFail
action is called and passed through.
Once it is, I’m expecting it to equal my initialState
value, because I’m resetting all loaded
and loading
props on a LOAD_PIZZAS_FAIL
action (we merge in all existing state inside the reducer as well - to not affect the entities
, but this doesn’t really matter for this test).
Let’s move onto the LOAD_PIZZAS_SUCCESS
action inside the reducer. This one’s interesting and I hope you like what’s about to be shown, as I’m assuming an array response from the JSON API, however the reducer maps this array to a flattened data structure of entities using Array.prototype.reduce
(you could move this out into a utility function for sure, or use @ngrx/entity
):
switch (action.type) {
case fromPizzas.LOAD_PIZZAS_SUCCESS: {
const pizzas = action.payload;
const entities = pizzas.reduce(
(entities: { [id: number]: Pizza }, pizza: Pizza) => {
return {
...entities,
[pizza.id]: pizza,
};
},
{
...state.entities,
}
);
return {
...state,
loading: false,
loaded: true,
entities,
};
}
}
So we can create both the expected JSON response and entities upfront, pass the array in, and compare the predicted structure:
describe('LOAD_PIZZAS_SUCCESS action', () => {
it('should populate entities from the array', () => {
const pizzas: Pizza[] = [
{ id: 1, name: 'Pizza #1', toppings: [] },
{ id: 2, name: 'Pizza #2', toppings: [] },
];
const entities = {
1: pizzas[0],
2: pizzas[1],
};
const { initialState } = fromPizzas;
const action = new fromActions.LoadPizzasSuccess(pizzas);
const state = fromPizzas.reducer(initialState, action);
expect(state.loaded).toEqual(true);
expect(state.loading).toEqual(false);
expect(state.entities).toEqual(entities);
});
});
The pizzas
array is what I’m expecting back from the aforementioned JSON response, obviously we mock the data here though, and then we map across each pizza to the entities
object manually.
Now the data’s ready and setup, we simply pass the array of pizzas into LoadPizzasSuccess
and await the new state from the reducer.
We then test each property accordingly against a result. You’ll also notice I’m using the toEqual(entities)
from the local function scope inside the test - I’m only creating the entities object for checking my desired outcome and nothing more.
Conclusion
Reducers are the lifeblood of the redux pattern, they make things tick so it’s important we test them correctly. It’s up to you how to compose them, and I hope you’ve learned a few tricks on how to setup your reducer depending on what you’d like to test.
Remember, they’re just pure functions, so you can modify state before and after calling them - and test your actions/results accordingly.
You can check out my NGRX app for more examples on testing reducers.