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, whosetypeis set to âNOOPâ. - We then invoke the
reducer()function specifyingundefinedfor the currentstatealong with the noopaction. - Finally, we
expectthat the resulting state is the same as theinitialState, which is the default value for thestateargument 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
AddUseraction, specifying a generated user that we created using faker. - We then invoke the
reducer()function, providing theinitialStateand theaction. - Finally, we
expecttheresultto equal a new object; first spreading theinitialStateobject, and then specifying theerrorandloadingproperty 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
updatedUserobject that uses the currentuserobject properties modifyingfirstNameandlastNameproperty 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
UpdateUserSuccessaction with theupdateproperty. Again, weâre using the @ngrx/entity library, so we specify anUpdatetype signature with theidandchangesproperties. - We invoke the
reducer()function providing theinitialStateand a newAddUserSuccessaction. - We assert that the
AddUserSuccessaction appropriately returns a newstateobject with a new user added. - Then, we invoke the
reducer()function again. This time we provide thestatethat was a result of theAddUserSuccessaction, along with theactioninstance ofUpdateUserSuccess. - Finally, we assert that the
resultof theUpdateUserSuccessaction 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.