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-9
any 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
TestActions
class that extends theActions
class in @ngrx/effects. The class adds a mutator (setter) foer thestream
property. - We also define a
getActions()
function that returns a new instance of theTestActions
class. We’ll provide this as a factory function for the Angular dependency injection in theTestBed
in place of theActions
class that is injected into theUserEffects
class. - Using the
beforeEach()
method we wire up theTestBed
, providing theUserEffects
class that we are testing in this example. We also mock out theUserService
usingjest.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
,UserEffects
andUserService
instances. - 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 specificAddUser
action. - We then
pipe()
the observable to a few operators. - First, we
map()
the observable stream data to return thepayload
object. - Secondly, we use the
exhaustMap()
operator to switch to the observable stream that is returned from theaddUser()
method in theUserService()
, specifying theuser
in thepayload
. - Third, we
map()
to theAddUserSuccess
action providing the payload object with the requireduser
property. - Finally we use the
catchError()
operator to catch an exceptions, returning a new observable of theAddUserFail
action. - 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 newUser
object using faker. - Then, we new up the
AddUser
action, specifying thepayload
object with the requireduser
property. - We also new up the
AddUserSuccess
action. - Then, we set the
stream
property within theTestActions
class. Recall that theactions
constant variable was retrieved from theTestBed
after we mocked theActions
dependency that is injected into theUserEffects
class. We set thestream
to a test hot observable, which after a frame emits anext
notification with theaction
. This basically sets up the action to be asserted into theActions
observable stream in NgRx. - We define the
response
constant as a cold observable, which after a frame emits a notification with theuser
and then emits a completion notification in the third frame. - We define the
expected
constant as a cold observable, which after two fames emits theoutcome
that is theAddUserSuccess
action that we are expecting. - Using the
jest.fn()
mock we overwrite theaddUser
method of theUserService
to return theresponse
- Finally,
expect()
that theaddUser
effect results in an observable that is theexpected
hot observable.
We also test the failure path:
- First we use the
generateUser
function to generate a fakeUser
object. - We then new up the
AddUser
andAddUserFail
actions. Note that we create a newError
object, keeping reference in order to use this when creating the cold observable stream for theresponse
. - We set the
stream
property in theactions
instance of theTestActions
class that was injected in theTestBed
. - Note that the
response
is a cold observable, which after a frame, emits an error notification (the#
pound sign) followed by a completion notification (the|
pipe symbol). - The
expected
observable is a cold observable, which after two frames, emits both theAddUserFail
action 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 observablestream
to include a single frame and the observableresponse
stream 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 theexpected
observable emits both theAddUserFail
action and the completion notification. - Just like in the previous test, we mock the
addUser
method to return an observable that is theresponse
. - Finally, we
expect()
that theaddUser
effect results in an observable that matches theexpected
hot 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.