Learn how to unit test NgRx effects using Jest.
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.
jasmine-marbles
We’ll be using the jasmine-marbles module in order to mock observables.
Here are some links to more information about marble testing with NgRx:
In summary, we can use marble-diagram-like strings to describe an observable stream over time. This enables us to synchronously test asynchronous observable streams.
There are two primary functions that we will be using:
hot()creates a hot observable stream.cold()creates a cold observable stream.
We’ll use these function to assert that the outcome of specific actions in our application result in the expected hot or cold observable stream that we describe.
Marble testing uses a string to describe the observable stream:
-a dash represents a frame of virtual time passing, which is 10 milliseconds, but that’s not really that important.a-z0-9any alphanumeric character represents a value. We can use the second argument to thehot()andcold()functions to specify the values that each marker represents.()paranthesis group together values into a single frame.^the carrot represents the start of a subscription in a cold observable stream.|the pipe represents the completion notification.#the pound sign (or hash symbol) represents the error notification.
Setup
The first step is to get the TestBed setup.
We’ll be mocking functions using jest.fn() as well as using a TestActions class so that we can modify the Observable source for an action.
All of the code in the examples is located in the demo application src/app/state/user/user.effects.spec.ts file.
export class TestActions extends Actions {
constructor() {
super(empty());
}
set stream(source: Observable<any>) {
this.source = source;
}
}
export function getActions() {
return new TestActions();
}
describe('UserEffects', () => {
let actions: TestActions;
let effects: UserEffects;
let userService: UserService;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
UserEffects,
{
provide: Actions,
useFactory: getActions
},
{
provide: UserService,
useValue: {
addUser: jest.fn(),
getUser: jest.fn(),
getUsers: jest.fn(),
updateUser: jest.fn()
}
}
]
});
actions = TestBed.get(Actions);
effects = TestBed.get(UserEffects);
userService = TestBed.get(UserService);
});
it('should be created', () => {
expect(effects).toBeTruthy();
});
});
Let’s review:
- First, we create the
TestActionsclass that extends theActionsclass in @ngrx/effects. The class adds a mutator (setter) foer thestreamproperty. - We also define a
getActions()function that returns a new instance of theTestActionsclass. We’ll provide this as a factory function for the Angular dependency injection in theTestBedin place of theActionsclass that is injected into theUserEffectsclass. - Using the
beforeEach()method we wire up theTestBed, providing theUserEffectsclass that we are testing in this example. We also mock out theUserServiceusingjest.fn(). These are just placeholders, which we will overwrite in the tests as necessary. - We then use the
TestBed.get()method to store a reference to theActions,UserEffectsandUserServiceinstances. - Finally, we have the standard “it should be created” test to ensure that our effects class was properly instantiated.
We’re now ready to start writing unit tests for each effect in src/app/state/user/user.effects.ts.
addUser Effect
First, let’s look at the addUser effect that we will be testing:
export class UserEffects {
@Effect()
addUser: Observable<Action> = this.actions$
.ofType<AddUser>(UserActionTypes.AddUser)
.pipe(
map(action => action.payload),
exhaustMap(payload => this.userService.addUser(payload.user)),
map(user => new AddUserSuccess({ user })),
catchError(error => of(new AddUserFail({ error })))
);
constructor(private actions$: Actions, private userService: UserService) {}
}
Let’s quickly summarize the addUser effect:
- We use the
ofType()method to filter all actions for the specificAddUseraction. - We then
pipe()the observable to a few operators. - First, we
map()the observable stream data to return thepayloadobject. - Secondly, we use the
exhaustMap()operator to switch to the observable stream that is returned from theaddUser()method in theUserService(), specifying theuserin thepayload. - Third, we
map()to theAddUserSuccessaction providing the payload object with the requireduserproperty. - Finally we use the
catchError()operator to catch an exceptions, returning a new observable of theAddUserFailaction. - The resulting actions from the effect are then dispatched.
Ok, let’s create a test suite for the addUser effect:
describe('addUser', () => {
it('should return an AddUserSuccess action, with the user, on success', () => {
const user = generateUser();
const action = new AddUser({ user });
const outcome = new AddUserSuccess({ user });
actions.stream = hot('-a', { a: action });
const response = cold('-a|', { a: user });
const expected = cold('--b', { b: outcome });
userService.addUser = jest.fn(() => response);
expect(effects.addUser).toBeObservable(expected);
});
it('should return an AddUserFail action, with an error, on failure', () => {
const user = generateUser();
const action = new AddUser({ user });
const error = new Error();
const outcome = new AddUserFail({ error });
actions.stream = hot('-a', { a: action });
const response = cold('-#|', {}, error);
const expected = cold('--(b|)', { b: outcome });
userService.addUser = jest.fn(() => response);
expect(effects.addUser).toBeObservable(expected);
});
});
Our test suite is composed of two tests.
First, we assert the success path; that the AddUserSuccess action is dispatched as a result of the effect.
Secondly, we assert the failure path; that the AddUserFail action is dispatched as a result of an exception in the effect.
Let’s dig into the details of the success path:
- First, we invoke
generateUser()to create a newUserobject using faker. - Then, we new up the
AddUseraction, specifying thepayloadobject with the requireduserproperty. - We also new up the
AddUserSuccessaction. - Then, we set the
streamproperty within theTestActionsclass. Recall that theactionsconstant variable was retrieved from theTestBedafter we mocked theActionsdependency that is injected into theUserEffectsclass. We set thestreamto a test hot observable, which after a frame emits anextnotification with theaction. This basically sets up the action to be asserted into theActionsobservable stream in NgRx. - We define the
responseconstant as a cold observable, which after a frame emits a notification with theuserand then emits a completion notification in the third frame. - We define the
expectedconstant as a cold observable, which after two fames emits theoutcomethat is theAddUserSuccessaction that we are expecting. - Using the
jest.fn()mock we overwrite theaddUsermethod of theUserServiceto return theresponse - Finally,
expect()that theaddUsereffect results in an observable that is theexpectedhot observable.
We also test the failure path:
- First we use the
generateUserfunction to generate a fakeUserobject. - We then new up the
AddUserandAddUserFailactions. Note that we create a newErrorobject, keeping reference in order to use this when creating the cold observable stream for theresponse. - We set the
streamproperty in theactionsinstance of theTestActionsclass that was injected in theTestBed. - Note that the
responseis a cold observable, which after a frame, emits an error notification (the#pound sign) followed by a completion notification (the|pipe symbol). - The
expectedobservable is a cold observable, which after two frames, emits both theAddUserFailaction next notification and the completion notification. Note that the timing is two frames before the next and completion notifications. This is because we modeled our previous observablestreamto include a single frame and the observableresponsestream to also include a single frame of time before the notifications of the action and error, respectively. Thus, it is after two frames of virtual time that theexpectedobservable emits both theAddUserFailaction and the completion notification. - Just like in the previous test, we mock the
addUsermethod to return an observable that is theresponse. - Finally, we
expect()that theaddUsereffect results in an observable that matches theexpectedhot observable.
loadUsers Effect
Before we look at testing the loadUsers effect, let’s quickly take a look at the effect:
@Effect()
loadUsers: Observable<Action> = this.actions$
.ofType<LoadUsers>(UserActionTypes.LoadUsers)
.pipe(
exhaustMap(() => this.userService.getUsers()),
map(users => new LoadUsersSuccess({ users })),
catchError(error => of(new LoadUsersFail({ error })))
);
As we look over this effect, there are a lot of similarities to the addUser effect.
In general, we’re using the UserService to retrieve all of the users and then mapping to the LoadUsersSuccess action.
If anything go awry, we dispatch the LoadUsersFail action.
Here is the test suite for the success and failure paths in the loadUsers effect:
describe('loadUsers', () => {
it('should return a LoadUsersSuccess action, with the users, on success', () => {
const users = generateUsers();
const action = new LoadUsers();
const outcome = new LoadUsersSuccess({ users: users });
actions.stream = hot('-a', { a: action });
const response = cold('-a|', { a: users });
const expected = cold('--b', { b: outcome });
userService.getUsers = jest.fn(() => response);
expect(effects.loadUsers).toBeObservable(expected);
});
it('should return a LoadUsersFail action, with an error, on failure', () => {
const action = new LoadUsers();
const error = new Error();
const outcome = new LoadUsersFail({ error: error });
actions.stream = hot('-a', { a: action });
const response = cold('-#|', {}, error);
const expected = cold('--(b|)', { b: outcome });
userService.getUsers = jest.fn(() => response);
expect(effects.loadUsers).toBeObservable(expected);
});
});
This too should look familar to the previous tests for the success and failure paths of the addUser effect.
The main differences are the different actions and required payloads for those actions.
Also, we write two tests just as we did for the addUser effect: one for the success path and another for the failure path.
The loadUser test suite is also very similar.
I’m going to skip it for the sake of brevity, but feel free to look over the tests in src/app/state/user/user.effects.spec.ts
updateUser Effect
Here is the updateUser effect that we will be testing:
@Effect()
updateUser: Observable<Action> = this.actions$
.ofType<UpdateUser>(UserActionTypes.UpdateUser)
.pipe(
map(action => action.payload),
exhaustMap(payload => this.userService.updateUser(payload.user)),
map(
user =>
new UpdateUserSuccess({
update: {
id: user.id,
changes: user
}
})
),
catchError(error => of(new UpdateUserFail({ error })))
);
In the updateUser effect we filter all actions for the specific UpdateUser action, and then use the exhaustMap() operator to switch to the observable that is returned from the UserService.updateUser() instance method.
We map() the success to the UpdateUserSuccess action that is dispatched from the effect, or we catchError() and return an observable of() the UpdateUserFail action.
The test suite for the updateUser effect:
describe('updateUser', () => {
it('should return an UpdateUserSuccess action, with the user, on success', () => {
const user = generateUser();
const action = new UpdateUser({ user });
const outcome = new UpdateUserSuccess({
update: {
id: user.id,
changes: user,
},
});
actions.stream = hot('-a', { a: action });
const response = cold('-a|', { a: user });
const expected = cold('--b', { b: outcome });
userService.updateUser = jest.fn(() => response);
expect(effects.updateUser).toBeObservable(expected);
});
it('should return an UpdateUserFail action, with an error, on failure', () => {
const user = generateUser();
const action = new UpdateUser({ user });
const error = new Error();
const outcome = new UpdateUserFail({ error });
actions.stream = hot('-a', { a: action });
const response = cold('-#|', {}, error);
const expected = cold('--(b|)', { b: outcome });
userService.updateUser = jest.fn(() => response);
expect(effects.updateUser).toBeObservable(expected);
});
});
We test both the success and failure paths, setting up the necessary observables and asserting that the resulting observable is what is expected.
Conclusion
Using the jasmine-marbles module we can easily test asynchronous effects using synchronous code by using a virtual timer (scheduler) and mocked values and actions.