Learn how to unit test NgRx reducers using Jest.
The goal is to assert the mutation to the state of the application as a result of dispatching an action. đď¸đď¸đŞđŞ
Download
In this post Iâll be working with a demo application. You can clone the repository or download a zip file of the source code:
Series
This post is part of a series on testing NgRx using Jest:
Stack
The stack for this application will be:
- Angular 6
- NgRx 6
- Jest and the angular-jest-preset module
- The jasmine-marbles module
- Faker for creating fake data
Jest Test Runner
As I mentioned above, this post will be using the Jest test runner. Itâs much faster than Karma (even with headless Chrome) and uses a Jasmine-like API. In fact, if most of your tests were generated by the CLI then you may be able to simply swap out Karma for Jest.
If youâre new to Jest, check out my post on using Jest with Angular.
NOOP Action
First, we should assert that the state is not mutated for an invalid or NOOP action in the reducer()
function in src/app/state/user/user.reducer.spec.ts:
describe('undefined action', () => {
it('should return the default state', () => {
const action = { type: 'NOOP' } as any;
const result = reducer(undefined, action);
expect(result).toBe(initialState);
});
});
Letâs review the test above:
- Within the âshould return the default stateâ spec we create a new
action
, whosetype
is set to âNOOPâ. - We then invoke the
reducer()
function specifyingundefined
for the currentstate
along with the noopaction
. - Finally, we
expect
that the resulting state is the same as theinitialState
, which is the default value for thestate
argument in thereducer()
function.
If we run our tests, it should pass:
$ npm test
AddUser
Action
In the example app I have specified some actions for adding a user in src/app/state/user/user.actions.ts:
export enum UserActionTypes {
AddUser = '[User] Add User',
AddUserSuccess = '[User] Add User Success',
AddUserFail = '[User] Add User Fail',
}
In the src/app/users/containers/add/add.component.ts component we dispatch()
the AddUser
action.
This will toggle the loading
boolean property in state, and the addUser
effect in src/app/store/user/user.effects.ts will invoke the UserService.addUser()
method, which will send a PUT request to our API to create a new user entity.
When the request completes, weâll dispatch the AddUserSuccess
action, or if the response was an error (e.g. 500 status code) weâll dispatch the AddUserFail
action.
Here is the definition of the AddUser
action:
export class AddUser implements Action {
readonly type = UserActionTypes.AddUser;
constructor(public payload: { user: Partial<User> }) {}
}
For the payload
we simply define an object that must have the user
property, which is a Partial
of a User
.
Next, letâs take a look at the reducer()
implementation for this action:
export function reducer(state = initialState, action: UserActions): State {
switch (action.type) {
case UserActionTypes.AddUser:
return { ...state, loading: true, error: undefined };
}
}
When the AddUser
action is dispatched we are updating the state so that the loading
boolean value is set to true
and ensuring that the error
property is set to undefined
.
Here is the test for the AddUser
action in the src/app/state/user/user.reducer.spec.ts file:
describe('User Reducer', () => {
describe('[User] Add User', () => {
it('should toggle loading state', () => {
const action = new AddUser({ user });
const result = reducer(initialState, action);
expect(result).toEqual({
...initialState,
error: undefined,
loading: true,
});
});
});
});
Letâs review the test:
- First, all of our tests are within the âUser Reducerâ test suite.
- Within the test, we new up the
AddUser
action, specifying a generated user that we created using faker. - We then invoke the
reducer()
function, providing theinitialState
and theaction
. - Finally, we
expect
theresult
to equal a new object; first spreading theinitialState
object, and then specifying theerror
andloading
property values.
AddUserSuccess
Action
Next, letâs assert that the AddUserSuccess
action returns the new state with the newly created user
entity added.
In this demo application I am using the @ngrx/entity module for managing a collection of User
objects.
While it is probably not necessary to assert state mutations that solely use the entity adapter, I wanted to explicitly test each action, including those that solely/primarily use the adapterâs methods (e.g. addOne()
) for mutating the state of the application.
In general, I think itâs safe to say:
Assume @ngrx/entity adapter state mutations are battle-tested and likely do not need to be tested in your application
First, here is the definition of the AddUserSuccess
action in src/app/state/user/user.actions.ts:
export class AddUserSuccess implements Action {
readonly type = UserActionTypes.AddUserSuccess;
constructor(public payload: { user: User }) {}
}
And, here is the case-statement in the reducer()
for the AddUserSuccess
action that returns the new state:
case UserActionTypes.AddUserSuccess:
case UserActionTypes.LoadUserSuccess: {
return adapter.addOne(action.payload.user, { ...state, loading: false });
}
As you can see, we invoke the addOne()
method on the entity adapter, providing the user that was created (or loaded) and toggle the loading
boolean property to false
.
Now, letâs look at the test in the src/app/state/user/user.reducer.spec.ts file:
describe('[User] Add User Success', () => {
it('should add a user to state', () => {
const action = new AddUserSuccess({ user });
const result = reducer(initialState, action);
expect(result).toEqual({
...initialState,
entities: {
[user.id]: user,
},
ids: [user.id],
loading: false,
});
});
});
In this test we invoke the reducer()
function, specifying the initialState
along with the AddUserSuccess
action that we created with the necessary payload.
We then expect()
the result to equal the initialState
with the additional entities
and ids
properties that are created via the adapterâs addOne()
method.
We also assert that the loading
boolean property has been set to false
.
This is really the only âmanualâ state mutation that is performed in the reducer that deserves to be tested.
AddUserFail
Action
We should also test the failure path for when the HTTP request for adding a new user fails.
To do this, weâll be asserting that the AddUserFail
action results in a new state of our application where the error
property contains the Error
that occurred.
First, here is the definition of the AddUserFail
action in src/app/state/user/user.actions.ts:
export class AddUserFail implements Action {
readonly type = UserActionTypes.AddUserFail;
constructor(public payload: { error: Error }) {}
}
And, here is the case-statement in the reducer()
function:
case UserActionTypes.AddUserFail:
case UserActionTypes.AddUsersFail:
case UserActionTypes.LoadUserFail:
case UserActionTypes.LoadUsersFail:
case UserActionTypes.UpdateUserFail:
case UserActionTypes.UpdateUsersFail: {
return { ...state, error: action.payload.error, loading: false };
}
Youâll note that in this application we have the same result when any of the actions fail: we update the error
property to contain the Error
that was dispatched in the actionâs payload
and toggle the loading
boolean to false
.
Now, letâs assert that the state is mutated appropriately when the AddUserFail
action is dispatched:
describe('[User] Add User Fail', () => {
it('should update error in state', () => {
const error = new Error();
const action = new AddUserFail({ error });
const result = reducer(initialState, action);
expect(result).toEqual({
...initialState,
error,
loading: false,
});
});
});
This should start to look familar.
We wire up the AddUserFail
action, invoke the reducer()
function, and assert that the result
is equal to the expected state of the application.
LoadUser
, LoadUserSuccess
and LoadUserFail
The tests for the LoadUser
, LoadUserSuccess
and LoadUserFail
are nearly identitical to those for adding a user.
Letâs quickly take a look at each one.
Assert that the LoadUser
action result
equals the expected value where the error
property is undefined
and the loading
property is toggled to true
:
describe('[User] Load User', () => {
it('should toggle loading state', () => {
const action = new LoadUser({ id: user.id });
const result = reducer(initialState, action);
expect(result).toEqual({
...initialState,
error: undefined,
loading: true,
});
});
});
Assert that the LoadUserSuccess
action result
equals the expected value where the entities
and ids
properties contain the user that was dispatched in the actionâs payload
:
describe('[User] Load User Success', () => {
it('should load a user to state', () => {
const action = new LoadUserSuccess({ user });
const result = reducer(initialState, action);
expect(result).toEqual({
...initialState,
entities: {
[user.id]: user,
},
ids: [user.id],
loading: false,
});
});
});
Assert that the LoadUserFail
action result
equals the expected value where the error
property contains the Error
that was dispatched in the actionâs payload
:
describe('[User] Load User Fail', () => {
it('should update error in state', () => {
const error = new Error();
const action = new LoadUserFail({ error });
const result = reducer(initialState, action);
expect(result).toEqual({
...initialState,
error,
loading: false,
});
});
});
LoadUsers
, LoadUsersSuccess
and LoadUsersFail
The unit tests for the LoadUsers
, LoadUsersSuccess
and LoadUsersFail
actions are similar, with the exception that the entities
and ids
properties are updated with many User
objects.
Weâll skip the LoadUsers
and LoadUsersFail
tests for the sake of brevity as they are nearly identical to the LoadUser
and LoadUserFail
tests.
Letâs quickly look at testing the LoadUsersSuccess
action:
describe('[User] Load Users Success', () => {
it('should add all users to state', () => {
const users = [user];
const action = new LoadUsersSuccess({ users });
const result = reducer(initialState, action);
expect(result).toEqual({
...initialState,
entities: users.reduce(
(entityMap, user) => ({
...entityMap,
[user.id]: user,
}),
{},
),
ids: users.map((user) => user.id),
loading: false,
});
});
});
In the expect()
assertion we use the Array.prototype.reduce()
method to create the entities
dictionary of user
objects.
The @ngrx/entity library uses the following signature to store the entities
in state for fast retrieval of objects:
export declare type DictionaryNum<T> = {
[id: number]: T;
};
export declare abstract class Dictionary<T> implements DictionaryNum<T> {
[id: string]: T;
}
Whether the unique identifier is a number
or a string
, the unique identifier is the property name (or key) in the entities
object.
This is why we use the reduce()
method to create the entities
object that matches the DictionaryNum
or Dictionary
type.
The ids
array is simply an array of the id
values.
We use the map()
method to map the values within the users
array of User
objects.
UpdateUserSuccess
The tests for the UpdateUser
and UpdateUserFail
actions are also mostly identical to the existing tests that we have looked at where we dispatch an action that toggles the loading
property and update the error
property.
On the other hand, wiring up a test that asserts the UpdateUserSuccess
action mutates the state of our application is a bit more complex, and quite frankly, a bit verbose:
describe('User Reducer', () => {
const user: User = {
id: 1,
firstName: 'Anakin',
lastName: 'Skywalker'
};
describe('[User] Update User Success', () => {
it('should update user in state', () => {
const updatedUser: User = {
...user,
firstName: 'Darth',
lastName: 'Vader'
};
const action = new UpdateUserSuccess({
update: {
id: user.id,
changes: updatedUser
}
});
const state = reducer(initialState, new AddUserSuccess({ user }));
expect(state).toEqual({
...initialState,
entities: {
[user.id]: user
},
ids: [user.id],
loading: false
});
const result = reducer(state, action);
expect(result).toEqual({
...state,
entities: {
...state.entities,
[user.id]: updatedUser
},
ids: [...state.ids],
loading: false
});
});
});
});
Here is what the UpdateUserSuccess
test is doing:
- First, we define an
updatedUser
object that uses the currentuser
object properties modifyingfirstName
andlastName
property values. Weâre going for a bit of a Star Wars theme here. If youâre opposed to Star Wars, just go along with it for this test. And if youâre a fan, hopefully youâll enjoy the bit of fun weâll be having writing unit tests for our Angular application. - We then new up an
UpdateUserSuccess
action with theupdate
property. Again, weâre using the @ngrx/entity library, so we specify anUpdate
type signature with theid
andchanges
properties. - We invoke the
reducer()
function providing theinitialState
and a newAddUserSuccess
action. - We assert that the
AddUserSuccess
action appropriately returns a newstate
object with a new user added. - Then, we invoke the
reducer()
function again. This time we provide thestate
that was a result of theAddUserSuccess
action, along with theaction
instance ofUpdateUserSuccess
. - Finally, we assert that the
result
of theUpdateUserSuccess
action represents theupdatedUser
.
UpdateUsersSuccess
The test for the UpdateUsersSuccess
action is similar to the test we just looked at for UpdateUserSuccess
with the addition of updating multiple users:
describe('User Reducer', () => {
const user: User = {
id: 1,
firstName: 'Anakin',
lastName: 'Skywalker'
};
describe('[User] Update Users Success', () => {
it('should add all users to state', () => {
const senator = {
id: 2,
firstName: 'Sheev',
lastName: 'Palpaatine'
};
const vader = {
...user,
firstName: 'Darth',
lastName: 'Vader'
};
const sidious = {
...senator,
firstName: 'Darth',
lastName: 'Sidious'
};
const originalUsers = [user, senator];
const updatedUsers = [vader, sidious];
const state = reducer(
initialState,
new AddUsersSuccess({
users: originalUsers
})
);
const action = new UpdateUsersSuccess({
update: [
{
id: user.id,
changes: vader
},
{
id: senator.id,
changes: sidious
}
]
});
const result = reducer(state, action);
expect(result).toEqual({
...state,
entities: updatedUsers.reduce(
(entityMap, user) => ({
...entityMap,
[user.id]: user
}),
{}
),
ids: updatedUsers.map(user => user.id),
loading: false
});
});
});
});
Iâm not going to review each line of code, but in general, we want to add the originalUsers
to the state of our application and then assert that updating multiple users via the UpdateUsersSuccess
action results in the expected state.
SelectUser
The last test in src/app/state/user/user.reducer.spec.ts is to assert that the SelectUser
action updates the selectedUser
property in our state object:
describe('[User] Select User', () => {
it('should set the selectedUserId property in state', () => {
const action = new SelectUser({ id: user.id });
const result = reducer(initialState, action);
expect(result).toEqual({
...initialState,
selectedUserId: user.id,
});
});
});
Run Tests
Finally, we run the tests using:
$ npm test
And we should see that all of our tests pass.
Conclusion
Writing unit tests can be fun! đ¤âď¸
No really, unit testing actions in your Angular application that uses NgRx can be accomplished pretty easily.
The only tests in these examples that were a bit tedious to write were the update tests, especially updating multiple entities at a time.
However, we now have excellent code coverage đâď¸đ and be assured that our actions are appropriately mutating the state via the reducer()
function.