Testing NgRx with Jest + Marbles

Brian Love

Google Developer Expert

Angular and Web Technologies

Demo Files

git clone git@github.com:blove/ngrx-testing.git

Stack

  • Angular 6
  • NgRx
  • jest + jest-preset-angular
  • jasmine-marbles
  • faker

Why Jest?

It's fast

Jest Test Runner

  • Uses jsdom
  • Execute tests in parallel
  • No need for karma
  • Configure via angular-jest-preset

Testing NgRx Actions

  • Do not need tests
  • Will be covered when testing dispatch()
  • Nothing to test

Testing NgRx Reducer

  • Assert no state mutation for invalid action
  • Assert state mutation for valid actions

Assert NOOP action

describe('undefined action', () => {
  it('should return the default state', () => {
    const action = { type: 'NOOP' } as any;
    const result = reducer(undefined, action);

    expect(result).toBe(initialState);
  });
});

Assert AddUser

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
    });
  });
});

Assert AddUserSuccess

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
    });
  });
});

Assert AddUserFail

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
    });
  });
});

Assert AddUserFail

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
    });
  });
});

Do I Test Entity State Mutations?

  • Likely, not necessary
  • It will affect code coverage
  • It's a bit tedious (user.reducer.spec.ts)

Testing NgRx Effects

  • Model the actions hot observable
  • Model any HTTPClient observables
  • Model and test dispatched Action(s)
  • Model and test catchError() action

Let's Review an Effect

@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: user })),
    catchError(error => of(new AddUserFail({ error })))
  );

Model and test an observable stream?

jasmine-marbles

npm i jasmine-marbles -D

yarn add jasmine-marbles -D

Marbles for Testing

  • Marbles provide a simple way to describe observable streams.
  • They can be used to model and test observables
  • They can also be used to model and test subscriptions
  • Uses a scheduler to provide timing
  • Timing is done frame-by-frame
  • Each character in a marble is 10 frames​
    • A frame is a virtual "millisecond" or "clock cycle" of the scheduler

Marble Diagrams

Marble Observable Syntax

  • The first character is "zero frame"
  • A dash "-" represents the passage of 10 frames
  • A pipe "|" indicates completion (.complete())
  • A hash "#" indicates error (.error())
  • A carrot "^" indicates subscription point in a hot observable
  • Any other character indicates emitted value (.next())

Emit a value and complete

  • A dash "-" represents the passage of 10 frames
  • The letter "a" represents our first emitted value
  • A pipe "|" represents completion
  • The below observable is 60 frames
cold("--a--|")

Emit a value and error

  • A dash "-" represents the passage of 10 frames
  • The letter "a" represents our first emitted value
  • A pipe "#" represents error
  • The below observable is 60 frames
cold("--a--#")

Multiple values & complete

  • A dash "-" represents the passage of 10 frames
  • The letter "a" represents our first emitted value
  • The letter "b" represents our second emitted value
  • The below observable is 80 frames
cold("--a-b--|")

Test Effect Success Path

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);
  });
});

Test Effect Failure Path

describe('addUser', () => {
  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);
  });
});

Testing NgRx Selectors

  • No need to test entity selectors
  • Use the projector() function to test projected values

Projected Selector

export const selectSelectedUserId = createSelector(
  selectUserState,
  fromUser.getSelectedUserId
);

export const selectSelectedUser = createSelector(
  selectUserEntities,
  selectSelectedUserId,
  (entities, selectedUserId) => entities && entities[selectedUserId]
);

Test Projected Selector

describe('User selectors', () => {
  const users = generateUsers(2);
  const entities = {
    [users[0].id]: users[0],
    [users[1].id]: users[1]
  };
  const selectedUser = users[0];

  it('should return null when entities is falsy', () => {
    expect(selectSelectedUser.projector(null, selectedUser.id)).toBe(null);
  });

  it('should get the retrieve the selected user', () => {
    expect(selectSelectedUser.projector(entities, selectedUser.id)).toBe(
      selectedUser
    );
  });
});

Test Dispatched Actions

  • Spy on dispatch()
  • Invoke dispatch() via component
  • Assert specific Action was dispatched

Test Dispatched Action

describe('ngOnInit', () => {
  it('should dispatch SelectUser action for specified id parameter', () => {
    const action = new SelectUser({ id: user.id });
    const spy = jest.spyOn(store, 'dispatch');

    expect(spy).toHaveBeenCalledWith(action);
  });

  it('should dispatch LoadUser action for specified id parameter', () => {
    const action = new LoadUser({ id: user.id });
    const spy = jest.spyOn(store, 'dispatch');

    expect(spy).toHaveBeenCalledWith(action);
  });
});

NgRx + Jest + Marbles

= 🦄 + 🌈 + 😎

Testing NgRx with Marbles

By Brian Love

Testing NgRx with Marbles

Learn how to test NgRx effects and reducers using Jest and jasmine-marbles.

  • 3,329