Unit testing observables doesn't have to be a mind pretzel.

As I start to build a membership site with the beginnings of my NX GCP Starter Project, I'm leveraging Firebase and NgRx for state management.  To keep code quality high and with code coverage set to 100%, this stretch goal lead to challenges I've uncovered in the past few days.

Goals

1. Install and Understand How to Use observer-spy

2. How to Unit Test NgRx Effects

3. Combine observer-spy with the NgRx Effects for Unit Testing

observer-spy

Start by installing observer-spy.

@hirez_io/observer-spy
A simple little class that helps making Observable testing a breeze. Latest version: 2.1.2, last published: 2 months ago. Start using @hirez_io/observer-spy in your project by running `npm i @hirez_io/observer-spy`. There are no other projects in the npm registry using @hirez_io/observer-spy.
npm install -D @hirez_io/observer-spy

observer-spy wraps observables in a more understandable syntaxes than jasime-marbles.  jasmine-marbles syntax shows frames using dashes to convey a unit of time, emitting values via a variable, pipe to complete, and a hash to signify an error.  NgRx Documentation examples have syntax using jasmine-marbles, but observer-spy is easier to understand expected output of an Observeable.

hot('-------a', {
  a: {
    type: '[Customers API] Search Customers Success',
    customers: [...],
  },
});

In the code above, the hot observable included eight frames of time before emitting the value a.  The definition of a follows the contract of an NgRx Action with the type and customers as the payload.

Using observer-spy the subscribeToSpy subscribes to an Observable capturing its output.  Depending on the number of values you want to capture, retrieving the first, last, or a subset, ObserverSpy exposes helper methods to read and store values from the stream.  Below, the getFirstValue retrieves the first value emitted from the authEffects.loginUserEffect$ stream.

obsSpy = subscribeSpyTo(authEffects.loginUserEffect$);
      expect(obsSpy.getFirstValue()).toEqual(new UserAuthActions.LoginUserSuccessAction());

Unit Testing NgRx Effects

Effects filter the stream of actions that cause side effects.  Looking through each effect created, take account of which action(s) is filtered, and the output of actions dispatched.

public loginUserEffect$: Observable<Action> = createEffect(() => this.actions$.pipe(
    ofType<UserAuthActions.LoginUserAction>(UserAuthActions.ActionTypes.LOGIN_USER),
    concatMap(({ payload: { authProvider, credentials } }) => {
      return this.userService.login(authProvider, credentials).pipe(
        map(() => new UserAuthActions.LoginUserSuccessAction()),
        catchError(err => [
          new UserAuthActions.LoginUserFailureAction(err)
        ])
      );
    })
  ));

Typically with side effects, communication with another stream such as a REST call or listening to SSE and Sockets results in side effects of changing the state.  Above, the userService.login definition returns an Observable.  This is our inner observable which will emit values to the outer stream.  Successful request emit and dispatch the UserAuthActions.LoginUserSuccessfulAction.  If there's an error, the inner Observable will emit a UserAuthActions.LoginUserFailureAction(err).

Next, I segregate each effect into it's own describe section to reduce cognitive load on where to fine each action / effect and only scope it to that action / effect.  Also, the userService methods to createUser will be mocked.

Free Your Developers

Nx Monorepo Starter alleviates developers from re-inventing deployments on popular Google Cloud Services, standardizes on libraries, and saves time for scaffolding projects.

View on Gumroad

Testing NgRx Effects with Observer Spy

Setting up the specific action that filters through the stream requires a utility exposed by the NgRx library, provideMockActions.   Define the $actions variable for the action to be tested.

  describe('createUserEffect$', () => {
    let authEffects: UserAuthEffects;
    let userService: UserService;
    let actions$: Observable<Action>;
    let obsSpy: SubscriberSpy<Action>;
    
    TestBed.configureTestingModule({
      ...moduleConfig,
      providers: [
        provideMockActions(() => actions$)
      ]
    });
    
    authEffects = TestBed.inject(UserAuthEffects);
    userService = TestBed.inject(UserService);

    actions$ = of(new UserAuthActions.CreateUserAccountAction({
      email: '',
      password: ''
    }));
  });

See the full test for this library.

it('should return CreateUserAccountSuccessAction', () => {
    userService.createUser = jest.fn(() => {
      return of({} as firebase.auth.UserCredential);
    });

    obsSpy = subscribeSpyTo(authEffects.createUserEffect$);
    expect(obsSpy.getFirstValue()).toEqual(new UserAuthActions.CreateUserAccountSuccessAction());
});

    it('should fail login', () => {
      const err = new Error('hootie hoo!');
      userService.createUser = jest.fn(() => {
        return throwError(() => err)
      });

      obsSpy = subscribeSpyTo(authEffects.createUserEffect$);
      expect(obsSpy.getFirstValue()).toEqual(new UserAuthActions.CreateUserAccountFailureAction(err));
    });

    afterEach(() => obsSpy?.unsubscribe());

The above code tests the UserAuthActions.ForgotUserPasswordAction.  In both tests, the userService.resetPassword is mocked to account for both successful and error responses which the stream will dispatch two different actions.

As these are side effects of a particular action, the action stream emits for either successful or unsuccessful actions when communicating with, for instance, an HTTP service.  Make sure in these cases, the inner stream handles the error actions to return rather than the outer stream.  Otherwise, the outer stream will unsubscribe.  See my three Pitfalls for further details.

The service requires mocking for a successful and an error response.  In this way, both actions emitted will be covered.

Conclusion

observer-spy captures values emitted from streams in a more developer friendly syntax than jasmine-marbles.  Wrapping the streams in subscribeToSpy holds the values from the stream to validate our tests.

When unit testing NgRx Effects, be aware of the actions that filter through the stream.  These will help coordinate which actions should be dispatched in the actions stream.

Finally, mock the successful and error responses for side effect.  Create tests to handle both successful and unsuccessful actions emitted from the effect.

Sources: https://ngrx.io/guide/effects/testing