Brian Love
Google Developer Expert in Angular, software engineer and skier located in Denver, CO

NgRx Testing: Effects

Reading time ~10 minutes

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 the hot() and cold() 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 the Actions class in @ngrx/effects. The class adds a mutator (setter) foer the stream property.
  • We also define a getActions() function that returns a new instance of the TestActions class. We’ll provide this as a factory function for the Angular dependency injection in the TestBed in place of the Actions class that is injected into the UserEffects class.
  • Using the beforeEach() method we wire up the TestBed, providing the UserEffects class that we are testing in this example. We also mock out the UserService using jest.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 the Actions, UserEffects and UserService 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 specific AddUser action.
  • We then pipe() the observable to a few operators.
  • First, we map() the observable stream data to return the payload object.
  • Secondly, we use the exhaustMap() operator to switch to the observable stream that is returned from the addUser() method in the UserService(), specifying the user in the payload.
  • Third, we map() to the AddUserSuccess action providing the payload object with the required user property.
  • Finally we use the catchError() operator to catch an exceptions, returning a new observable of the AddUserFail 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 new User object using faker.
  • Then, we new up the AddUser action, specifying the payload object with the required user property.
  • We also new up the AddUserSuccess action.
  • Then, we set the stream property within the TestActions class. Recall that the actions constant variable was retrieved from the TestBed after we mocked the Actions dependency that is injected into the UserEffects class. We set the stream to a test hot observable, which after a frame emits a next notification with the action. This basically sets up the action to be asserted into the Actions observable stream in NgRx.
  • We define the response constant as a cold observable, which after a frame emits a notification with the user and then emits a completion notification in the third frame.
  • We define the expected constant as a cold observable, which after two fames emits the outcome that is the AddUserSuccess action that we are expecting.
  • Using the jest.fn() mock we overwrite the addUser method of the UserService to return the response
  • Finally, expect() that the addUser effect results in an observable that is the expected hot observable.

We also test the failure path:

  • First we use the generateUser function to generate a fake User object.
  • We then new up the AddUser and AddUserFail actions. Note that we create a new Error object, keeping reference in order to use this when creating the cold observable stream for the response.
  • We set the stream property in the actions instance of the TestActions class that was injected in the TestBed.
  • 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 the AddUserFail 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 observable stream to include a single frame and the observable response 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 the expected observable emits both the AddUserFail action and the completion notification.
  • Just like in the previous test, we mock the addUser method to return an observable that is the response.
  • Finally, we expect() that the addUser effect results in an observable that matches the expected 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.

Brian Love

Hi, I'm Brian. I am interested in TypeScript, Angular and Node.js. I'm married to my best friend Bonnie, I live in Denver and I ski (a lot).