Picture of Brian Love wearing black against a dark wall in Portland, OR.

Brian Love

NgRx Testing: Effects

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:

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:

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:

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:

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:

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:

We also test the failure path:

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.