Testing RxJS Service with a Subject Component Interaction

The service with a subject pattern is a good entry point into the RxJS world of observables in Angular. When you are using RxJS, writing traditional tests can be cumbersome. There are great tools, such as jasmine-marbles, that help make these kinds of tests a lot easier. A great way to get started with jasmine-marbles is testing the service with a subject when the service only performs HTTP calls. However, when the service also accepts updates from components, writing these tests can be more difficult as you have to bridge the gap between the timing of function calls and observables.

The Service under Test

To illustrate a starting point for how to write these kinds of tests, consider a service that supports a component that allows the user to pick the sauce they would like to have on their burger 🍔. You might come up with a sauce service to keep track of which sauce was picked, such as the one shown below.
sauce.service.ts

// sauce.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class SauceService {
  private mSauce$ = new BehaviorSubject<string>('initial sauce');

  constructor() {}

  sauce$(): Observable<string> {
    return this.mSauce$;
  }

  setSauce(sauce: string) {
    this.mSauce$.next(sauce);
  }
}

Testing the Service

There are two things you may be interested in testing. The first is that the initial sauce is set correctly and the second is that, on calling setSauce, that the new sauce is emitted by sauce$. Both of these can be tested using jasmine-marbles. Without jasmine-marbles, you would have to subscribe to sauce$, check that the correct sauces are emitted and make use of the done function. With jasmine-marbles this can be simplified.

To see how, you need to understand that the jasmine-marbles cold function simply creates an observable. Just like any other observable, you can subscribe to it. So if you define an observable that emits the sauces that you want to call setSauce with using cold, subscribe to that observable and call setSauce with the sauces that you receive, you have bridged the gap between function calls an observables. The marble syntax allows you to specify the timing of the setSauce calls. The tests could like like what is shown below.
sauce.service.spec.ts

// sauce.service.spec.ts
import { cold } from 'jasmine-marbles';

import { SauceService } from './sauce.service';


describe('SauceService', () => {
  let service: SauceService;

  beforeEach(() => {
    service = new SauceService();
  });

  describe('construction', () => {
    it('should emit initial sauce', () => {
      // Setting up expected observable
      const expectedMarbles = 'a';
      const expected = cold(expectedMarbles, {a: 'initial sauce'});

      // Checking sauce$
      expect(service.sauce$()).toBeObservable(expected);
    });
  });

  describe('setSauce', () => {
    it('should emit new sauce', () => {
      // Defining marbles
      const setSauceCallMarbles = '-a';
      const expectedMarbles =     'bc';

      // Setting up setSauce call
      cold(setSauceCallMarbles, {a: 'new sauce'})
        .subscribe(sauce => service.setSauce(sauce));

      // Checking sauce$
      const expected = cold(expectedMarbles, {b: 'initial sauce', c: 'new sauce'});
      expect(service.sauce$()).toBeObservable(expected);
    });
  });
});

The test on lines 15-22 checks that the initial sauce is emitted after construction. The interesting test is on lines 26-39. On line 28 the timing of the setSauce calls is defined. In this case, it is setup so that the first setSauce call occurs one frame after the construction is complete. On lines 32-33 the input to the setSauce call is defined and the plumbing to perform the setSauce calls is specified. On lines 36-37 the standard work is being done to check the values emitted by sauce$.

This technique is more concise and easy to understand than messing around with observables, subscriptions and the done function. There are still things that could be better. For example, it would be great if jasmine-marbles could come up with a standardised way of performing this technique (maybe they already have and I just don’t know about it). You may not feel like using this technique all the time, depending on the complexity of the logic and its dependence on timing. However, knowing about it and how it helps you can be valuable when you encounter the need for finer control over timing of function calls in tests.

Testing Reactive Service with a Subject in Angular

The Service with a Subject pattern in Angular is a good way to get started with reactive programming (ie. making use of RxJS) in Angular. The following article describes how to get started with the pattern: Getting Started with Service with a Subject in Angular.

The next step after implementing the Service with a Subject pattern is to write tests for the service. These tests will involve working with observables. Testing observables with plain Jasmine leads to tests with logic not related to verifying that the service works. The jasmine-marbles library includes several methods that help test observables.

The Service with a Subject Under Test

For reference, the following is the service that needs to be tested.
name.service.ts

// name.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { BehaviorSubject, Observable, of } from 'rxjs';

@Injectable({
  providedIn: 'root'
})
export class NameService {
  private mName$ = new BehaviorSubject<string>(null);

  name$(): Observable<string> {
    return this.mName$;
  }

  constructor(private httpClient: HttpClient) { }

  loadName() {
    this.httpClient.get<string>('name URL')
      .subscribe(
        name => this.mName$.next(name),
        () => null
      );
  }
}

To figure out what needs to be tested, let’s think about what could go wrong. There are 3 main things that are likely to go wrong. The first is that the incorrect URL is passed to the get function of the HttpClient. The second is that the return value of the HTTP GET request is not passed to the subject correctly. The third is that, if the HTTP GET request returns an error, an exception is raised.

Service with a Subject Tests

The following is the test code that checks the 3 things that are likely to go wrong described earlier.
name.service.spec.ts

// name.service.spec.ts
import { HttpClient } from '@angular/common/http';
import { cold } from 'jasmine-marbles';

import { NameService } from './name.service';

describe('NameService', () => {
  let service: NameService;
  let httpClientSpy: jasmine.SpyObj<HttpClient>;

  beforeEach(() => {
    httpClientSpy = jasmine.createSpyObj('HttpClient', ['get']);
    service = new NameService(httpClientSpy);
  });

  describe('loadName', () => {
    it('should call get on HttpClient with correct URL', () => {
      // Setting up get spy
      httpClientSpy.get.and.returnValue(cold('a|', {a: null}));

      // Calling loadName
      service.loadName();

      // Checking get call
      expect(httpClientSpy.get).toHaveBeenCalledWith('name URL');
    });

    it('should emit new name on name$', () => {
      // Defining marbles
      const getSpyMarbles =   '-a|';
      const expectedMarbles = 'bc';
      // Setting up get spy
      httpClientSpy.get.and.returnValue(cold(getSpyMarbles, {a: 'name'}));

      // Calling loadName
      service.loadName();

      // Checking get call
      const expected = cold(expectedMarbles, {b: null, c: 'name'});
      expect(service.name$()).toBeObservable(expected);
    });

    it('should emit nothing on name$ if an error occurs', () => {
      // Defining marbles
      const getSpyMarbles =   '-#|';
      const expectedMarbles = 'a';
      // Setting up get spy
      httpClientSpy.get.and.returnValue(cold(getSpyMarbles));

      // Calling loadName
      service.loadName();

      // Checking get call
      const expected = cold(expectedMarbles, {a: null});
      expect(service.name$()).toBeObservable(expected);
    });
  });
});

Test Setup

The setup common to all tests is on lines 8-14. Lines 8 and 9 define the service under test and the HttpClient spy used for the tests that are initialised on lines 11-14. The three test scenarios are defined on lines 17-26 (checking the URL passed to the HttpClient get function), 28-41 (checking that the return value of the HTTP GET request is passed on) and 43-56 (checking the behaviour of the service when the HTTP GET returns an error).

GET Request URL

The test on lines 17-26 checks that the correct URL is passed to the get function of the HttpClient. The first step on line 19 is to give the get call a return value. In this case we only care about what is passed to the get function, so what is set as the return value is irrelevant as long as it is an observable. Line 22 triggers the function that triggers the GET request. Line 25 checks that the get function was called with the correct URL.

Return Value is Passed On

The test on line 28-41 checks that the HTTP GET request return value is passed on by the name$ function as an observable. Lines 30 and 31 define marbles that control when the HTTP GET request emits a value and when the name$ function should emit a value, respectively. Line 33 defines what the HTTP GET request returns.

Line 36 triggers the function that triggers the HTTP GET request. Line 39 defines that value that is expected to be emitted by the name$ function (being the null with which the BehaviourSubject is initialised and the GET request return value) and line 40 checks for that expectation.

HTTP GET Request Error

The test on lines 43-56 checks the behaviour of the code if the HTTP GET request returns an error. Lines 45 and 46 define marbles that control when the HTTP GET request returns an error (indicated by #) and when the name$ function should emit a value, respectively. Line 48 defines what the HTTP GET request should return.

Line 51 triggers the function that triggers the HTTP GET request. Line 54 defines that value that is expected to be emitted by the name$ function (being only the null with which the BehaviourSubject is initialised) and line 55 checks for that expectation.

These tests are the base on which tests can be built if the logic in the service becomes more complicated. Examples of additional complications are if the HTTP GET return value needs to be transformed or if multiple HTTP GET calls need to be made that are potentially merged together.