author avatar
By Zach Tindall Senior Software Engineer

*Views, thoughts, and opinions expressed in this post belong solely to the author, and not necessarily to SemanticBits.

Introduction

I’ve been working with observables ever since the beta version of Angular was released to the public. Learning how observables behave is complicated enough, but then you also have to learn how each of the operators behave and then, the most important part, how to actually test the code that uses observables.

There are two ways to test observables. The first way is the path of least resistance for most developers–subscribe and assert. The second way is to test by using the marble diagrams pattern.

I have come across posts from time to time about using the second approach and, each time, the blog post left my head spinning. Using the marbles pattern can be very difficult to understand and I’ve always opted for the first approach because of one simple reason–I couldn’t quite understand the testing code. Then one day I was working on a project for a proposal and I decided it was time to dig in and figure out how to use this testing pattern. After a few failed attempts, it finally clicked and I had my aha moment. In the rest of this post, I’m going to help you have an aha moment of your own.

Why marbles?

If you truly understand how observables work, then it’s easy to see how an observable can best be represented by a visual diagram. Let’s take a look.

of(1, 2, 3).pipe(
     delay(2), 
     map(x => x + 1)
);

In this simple example, the observable will emit 3 values (1, 2, and 3). Once they are emitted, a delay of 2 milliseconds will occur and then finally each value is incremented by 1. A visual representation of this observable would look like this:

 

 

 

 

 

 

 

Since the execution of an observable can so easily be represented by a visual diagram, it only makes sense to write a test that can also be represented by a visual diagram, right?

Understand the lingo

Before we actually dive into some examples, I first want to take some time to define a few methods used in marble diagrams testing, as well as the syntax to represent the journey through time.

hot(): hot observables start producing values immediately after being created. You’ll often see a hot observable in marble diagrams testing created like:

hot(`--^--a--b`, { a: `foo`, b: `bar` })

If you break this down a bit, it’s actually very simple to understand.

  1. The observable is created
  2. After 2 frames, something has subscribed to the observable, represented by the caret
  3. After 2 more frames, the first value is emitted, `foo`
  4. After 2 more frames, the next value is emitted, `bar`

cold(): cold observables start running upon subscription. You’ll often see a cold observable in marble diagrams testing created like:

cold(`--a--(b|)`, { a: `foo`, b: `bar` })

Let’s also break this one down.

  1. The observable starts upon subscription, so you’ll never see a caret in this diagram
  2. After 2 frames, the first value is emitted, `foo`
  3. After 2 more frames, the next value is emitted, `bar` and then the observable completes

Marble Diagrams Syntax

Source: github

  • ' 'whitespace: horizontal whitespace is ignored, and can be used to help vertically align multiple marble diagrams.
  • '-' frame: 1 “frame” of virtual time passing.
  • [0-9]+[ms|s|m]time progression: the time progression syntax lets you progress virtual time by a specific amount. It’s a number, followed by a time unit of ms (milliseconds), s (seconds), or m (minutes) without any space between them.
  • '|'complete: The successful completion of an observable. This is the observable producer signaling complete().
  • '#'error: An error terminating the observable. This is the observable producer signaling error().
  • [a-z0-9]e.g., ‘a’ any alphanumeric character: Represents a value being emitted by the producer signaling next().
  • '()'sync groupings: When multiple events need to be in the same frame synchronously, parentheses are used to group those events.
  • '^'subscription point: (hot observables only) shows the point at which the tested observables will be subscribed to the hot observable.

Quick examples:

`--a--b--|`

  1. 2 frames pass then the value of a is emitted
  2. 2 more frames pass and then value of b is emitted
  3. 2 more frames pass and then the observable completes

`--^--a--#`

  • 2 frames pass and then the observable is subscribed
  • 2 more frames pass and then the value of a is emitted
  • 2 more frames pass and then an error occurs

`2s a 2s b`

  1. 2 seconds pass and then the value of a is emitted
  2. 2 more seconds pass and then the value of b is emitted

`--(ab)--(cd)--|`

  1. 2 frames pass and then the values of a and b are emitted
  2. 2 more frames pass and then the values of c and d are emitted
  3. 2 more frames pass and then the observable completes

Out of context first

Before we look at our first real-world example, I wanted to show you how we would test the above example with just rxjs and marble diagrams. Let’s look at the example again.

of(1, 2, 3).pipe(
     delay(2), 
     map(x => x + 1)
);

To test this code we can use the TestScheduler from rxjs and create a cold observable.

import { TestScheduler } from 'rxjs/testing';
import { map, delay } from 'rxjs/operators';

describe('Marble Diagrams testing', () => {
 let testScheduler: TestScheduler;

 beforeEach(() => {
   // Set up the TestScheduler to assert with the framework your using (Jasmine in this case)
   testScheduler = new TestScheduler((actual, expected) => {
     expect(actual).toEqual(expected);
   });
 });

 it('should output the correct sequence', () => {
   testScheduler.run(({ cold, expectObservable }) => {
     // create the source to mimic `of(1, 2, 3)`
     const source$ = cold('--a--b--c|', { a: 1, b: 2, c: 3 });
     // pipe the delay and then add 1 to each emitted value
     const result$ = source$.pipe(delay(2), map((x: number) => x + 1));

     // assert
     expectObservable(result$).toBe('-- 2ms a--b--c|', {
       a: 2,
       b: 3,
       c: 4
     });
   });
 });
});

If we break this down a bit, it’s not so hard to understand. The first part of the test creates the source observable. This observable, according to the marble diagrams, will emit three values—1, 2, and 3—and then complete. The values are then piped to the delay operator which will delay any further emission for 2 milliseconds. After the delay, we send each value to the map operator to be incremented by 1.

The resulting marble diagram would then be `-- 2ms a--b--c|` with values of 2, 3, and 4.  To see how we got this final diagram, let’s dig in a bit.

  • When the source observable starts running, it first waits 2 frames represented by `--`.
  • Next, the value of a is emitted and passed to the delay operator to wait for 2 milliseconds.
  • While the delay is running for the first value, the source observable is still running and will emit the value of b two frames after it emitted a, and then it will emit the value of c two frames after it emits b.
  • At this point the source observable completes, represented by the pipe character.
  • Once the delay of 2 milliseconds completes for the value of a, it is then incremented by 1 and the time progression of this value through its journey is `-- 2ms a`.
  • Since the value of b and the value of c are 2 frames behind a, we can conclude that, since the delay is the same for all values, the remaining part of the diagram would be `--b--c|`.

Now to the good stuff

Now it’s time for some actual examples! I’m going to show some real-world examples and how the marble diagrams testing patterns can actually save you some grief in the future.

The first example here is a very common scenario when working in an Angular application. Let’s first take a look at the code:

import { Component, AfterViewInit, ViewChild, ElementRef } from '@angular/core';
import { Observable, of, fromEvent, merge } from 'rxjs';
import { mergeMap, delay, map } from 'rxjs/operators';

const states = {
 sc: ['Charleston', 'Columbia', 'Greenville'],
 nc: ['Asheville', 'Charlotte', 'Raleigh']
};

@Component({
 selector: 'app-example1',
 template: `
<!-- return the cities for SC after a delay of 3 seconds -->
<button id="sc" #sc data-state="sc" data-delay="3000">Show cities in South Carolina</button>
<!-- return the cities for NC after a delay of 1 seconds -->
<button id="nc" #nc data-state="nc" data-delay="1000">Show cities in North Carolina</button>
<ul>
 <li *ngFor="let city of cities$ | async">{{ city }}</li>
</ul>`
})
export class Example1Component implements AfterViewInit {
 cities$: Observable<string[]>;

 @ViewChild('sc') scButton: ElementRef;
 @ViewChild('nc') ncButton: ElementRef;

 ngAfterViewInit(): void {
   // Combine the button click events into a single stream of events
   const events$ = merge(
     fromEvent(this.scButton.nativeElement, 'click'),
     fromEvent(this.ncButton.nativeElement, 'click')
   ).pipe(
     map((event: MouseEvent) => {
       const button = event.target as HTMLButtonElement;
       const state = button.dataset['state'];
       const delayMilli = parseInt(button.dataset['delay'], 10);
       return { state, delayMilli };
     })
   );

   this.cities$ = this.filterCities(events$);
 }

 filterCities(events$: Observable<{ state: string, delayMilli: number }>): Observable<string[]> {
   return events$.pipe(
     mergeMap(evt => {
       // simulate an http request to get the cities for the state button clicked
       return this.getHttpResults(evt.state, evt.delayMilli);
     })
   );
 }

 /**
  * Simulate an http request with a specified delay
  */
 private getHttpResults(state: string, delayMilli: number): Observable<string[]> {
   const results = states[state];
   return of(results).pipe(
     delay(delayMilli)
   );
 }
}

So, this is pretty simple. We have 2 buttons on the page. Each button represents a state, SC and NC. When you click a button, an http request is made to fetch the cities for the state and then the cities are displayed on the screen.

Can you spot the race condition? Maybe you can, but let’s write a test to help us find the issue.

import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { TestScheduler } from 'rxjs/testing';

import { Example1Component } from './example1.component';

describe('Example1Component', () => {
 let component: Example1Component;
 let fixture: ComponentFixture<Example1Component>;
 let testScheduler: TestScheduler;

 beforeEach(async(() => {
   TestBed.configureTestingModule({
     declarations: [Example1Component]
   })
     .compileComponents();
 }));

 beforeEach(() => {
   fixture = TestBed.createComponent(Example1Component);
   component = fixture.componentInstance;
   fixture.detectChanges();

   testScheduler = new TestScheduler((actual, expected) => {
     expect(actual).toEqual(expected);
   });
 });

 it('should display the cities for the last clicked state button', () => {
   testScheduler.run(({ hot, expectObservable }) => {

     // simulate button clicks by pressing SC after 1 frame, waiting 1 frame and then pressing NC
     const result = component.filterCities(
       hot('-a-b', {
         a: { state: 'sc', delayMilli: 10 },
         b: { state: 'nc', delayMilli: 1 }
       })
     );

     // expected result would be the results for NC on the 5th frame
     // however, because of the mergeMap operator used, both results are returned.
     // NC results on the 5th frame, and SC results on the 11th frame

     // ***************This will Fail*************************
     expectObservable(result).toBe('----b', {
        b: ['Asheville', 'Charlotte', 'Raleigh']
      });

     // this is the actual result of the test which means if you press SC first and then NC right after, you
     // will still see the cities for SC

     // ***************This will Pass*************************
     expectObservable(result).toBe('----b------a', {
       a: ['Charleston', 'Columbia', 'Greenville'],
       b: ['Asheville', 'Charlotte', 'Raleigh']
     });
   });
 });
});

Explanation

Because of the mergeMap operator in this example, both http requests will continue to execute, and the results of each will then update the city list on the page once the http request completes. However, since the http request for the SC cities takes longer than the http request for the NC cities, pressing the button for SC first and then quickly pressing the button for NC will still result in showing the cities for SC on the screen.

Okay, we have a problem. Let’s fix it!

Let’s update the code.

Change:

filterCities(city: Observable<{ state: string, delayMilli: number }>): Observable<string[]> {
   return city.pipe(
     mergeMap(cityData => {
       // simulate an http request to get the cities for the state button clicked
       return this.getHttpResults(cityData.state, cityData.delayMilli);
     })
   );
 }

To:

filterCities(city: Observable<{ state: string, delayMilli: number }>): Observable<string[]> {
   return city.pipe(
     switchMap(cityData => {
       // simulate an http request to get the cities for the state button clicked
       return this.getHttpResults(cityData.state, cityData.delayMilli);
     })
   );
 }

Now, if we go back and run the test from above, the test that we expect to pass actually will pass. Here’s a brief explanation of the difference between mergeMap and switchMap.

Cool! Now let’s take a look at something a bit more complicated.

One of the great third-party libraries we use here at SemanticBits is @ngrx/effects. If you aren’t familiar with this library, you can read “Understanding @ngrx/effects” written by Senior Software Engineer John McEntee.

Once you get into the thick of writing effects, you’ll quickly learn that some effects can become very complicated very quickly. Having a good set of tests around your effects is über important and can save you from a massive headache during large refractors and when tracking down race conditions.

Now, let’s take the same scenario from above but written into an effect.

import { Injectable } from '@angular/core';
import { Actions, Effect, ofType } from '@ngrx/effects';
import { Action } from '@ngrx/store';
import { Observable, of, forkJoin } from 'rxjs';
import { switchMap, map, catchError } from 'rxjs/operators';
import { StateActionTypes, LoadCitiesAction, LoadCitiesSuccess, LoadCitiesFail, LoadAllStateCitiesAction } from '../actions/state';
import { StateService, states } from '../../services/state.service';

@Injectable()
export class StateEffects {

 @Effect()
 loadCities$: Observable<Action> = this.actions$.pipe(
   ofType(StateActionTypes.LOAD_CITIES),
   switchMap((action: LoadCitiesAction) => this.service.getHttpResults(action.payload.state, action.payload.delayMilli).pipe(
     map(cities => new LoadCitiesSuccess({ cities })),
     catchError(err => of(new LoadCitiesFail({ error: new Error(err.message) })))
   ))
 );

 @Effect()
 loadAllStateCities$: Observable<Action> = this.actions$.pipe(
   ofType(StateActionTypes.LOAD_ALL_STATE_CITIES),
   switchMap((action: LoadAllStateCitiesAction) => {
     const keys = Object.keys(states);
     const observablesToMerge = keys.map(key => this.service.getHttpResults(key, action.payload.delayMilli));
     return forkJoin(...observablesToMerge).pipe(
       map(results => results.reduce((accum: string[], current: string[]) => {
         return accum.concat(current);
       }, [])),
       map(cities => new LoadCitiesSuccess({ cities })),
       catchError(err => of(new LoadCitiesFail({ error: new Error(err.message) })))
     );
   })
 );

 constructor(
   private actions$: Actions,
   private service: StateService) { }
}

Anytime we fire an action of type LOAD_CITIES, we will call the http endpoint to retrieve a list of cities for a state.

I’ve also added an additional action to load all cities for all states just to give you a little more of a complicated example for us to test. Also, note that we are injecting a state service, which is an Angular service that is actually responsible for making the http request for getting the city results. It’s important that we inject this service into the effects so that we can mock the service as needed for testing purposes.

The Test Setup

import { TestScheduler } from 'rxjs/testing';

describe('State Effects', () => {
 let testScheduler: TestScheduler;

 beforeEach(() => {
   // Set up the TestScheduler to assert with the framework your using (Jasmine in this case)
   testScheduler = new TestScheduler((actual, expected) => {
     expect(actual).toEqual(expected);
   });
 });
)};

Testing the first effect, loadCities$

it('should call success with the result after loading cities', () => {
   testScheduler.run(({ cold, expectObservable }) => {
     // create the action we are testing as a cold observable
     const action$ = cold('-a', {
       a: new StateActions.LoadCitiesAction({ state: 'sc', delayMilli: 1 })
     }) as any;

     // mock the StateService to return the desired results
     const stateService = {
       getHttpResults: (): Observable<string[]> => cold('1ms (a|)', { a: ['Charleston', 'Columbia', 'Greenville'] })
     };

     // run the action through the effects
     const effects = new StateEffects(new Actions(action$), stateService);

     expectObservable(effects.loadCities$).toBe('- 1ms a', {
       a: new StateActions.LoadCitiesSuccess({ cities: ['Charleston', 'Columbia', 'Greenville'] })
     });
   });
 });

 it('should call fail with the error when loading cities has an error', () => {
   testScheduler.run(({ cold, expectObservable }) => {
     // create the action we are testing as a cold observable
     const action$ = cold('-a', {
       a: new StateActions.LoadCitiesAction({ state: 'sc', delayMilli: 1 })
     }) as any;

     // mock the StateService to return an error after 1ms
     const stateService = {
       getHttpResults: (): Observable<string[]> => cold('1ms #', {}, { status: 500, message: 'Server Error' })
     };

     // run the action through the effects
     const effects = new StateEffects(new Actions(action$), stateService);

     expectObservable(effects.loadCities$).toBe('- 1ms a', {
       a: new StateActions.LoadCitiesFail({ error: new Error('Server Error') })
     });
   });
 });

 it('should cancel previous request when a new one action is fired', () => {
   testScheduler.run(({ cold, expectObservable }) => {
     // create the actions we are testing as a cold observable
     const action$ = cold('-a-b', {
       a: new StateActions.LoadCitiesAction({ state: 'sc', delayMilli: 2 }),
       b: new StateActions.LoadCitiesAction({ state: 'nc', delayMilli: 1 })
     }) as any;

     // mock the StateService to return the desired results after the specified delay
     const stateService = {
       getHttpResults: (state: string, delayMilli: number): Observable<string[]> => cold(`${delayMilli}ms (a|)`, {
         a: state === 'sc' ? ['Charleston', 'Columbia', 'Greenville'] : ['Asheville', 'Charlotte', 'Raleigh']
       })
     };

     // run the action through the effects
     const effects = new StateEffects(new Actions(action$), stateService);

     // one frame passes, then on frame 2 an action is fired.
     // after another frame, a new action is fired that cancels the first one,
     // then after a 1ms delay the result is emitted
     expectObservable(effects.loadCities$).toBe('--- 1ms b', {
       b: new StateActions.LoadCitiesSuccess({ cities: ['Asheville', 'Charlotte', 'Raleigh'] })
     });
   });
 });

Test for the second effect, loadAllStateCities$

it('should call success with the result after loading all cities', () => {
   testScheduler.run(({ cold, expectObservable }) => {
     // create the action we are testing as a cold observable
     const action$ = cold('-a', {
       a: new StateActions.LoadAllStateCitiesAction({ delayMilli: 2 })
     }) as any;

     // mock the StateService to return the desired results
     const stateService = {
       getHttpResults: (state: string): Observable<string[]> => cold('2ms (a|)', {
         a: state === 'sc' ? ['Charleston', 'Columbia', 'Greenville'] : ['Asheville', 'Charlotte', 'Raleigh']
       })
     };

     // run the action through the effects
     const effects = new StateEffects(new Actions(action$), stateService);

     // one frame passes, then a 2ms delay occurs, then the result is emitted
     expectObservable(effects.loadAllStateCities$).toBe('- 2ms a', {
       a: new StateActions.LoadCitiesSuccess({ cities: ['Charleston', 'Columbia', 'Greenville', 'Asheville', 'Charlotte', 'Raleigh'] })
     });
   });
 });

 it('should call fail with the error when loading all cities has an error', () => {
   testScheduler.run(({ cold, expectObservable }) => {
     // create the action we are testing as a cold observable
     const action$ = cold('-a', {
       a: new StateActions.LoadAllStateCitiesAction({ delayMilli: 2 })
     }) as any;

     // mock the StateService to return an error
     const stateService = {
       getHttpResults: (): Observable<string[]> => cold('-#', {}, { status: 500, message: 'Server Error' })
     };

     // run the action through the effects
     const effects = new StateEffects(new Actions(action$), stateService);

     // one frame passes, then a 2ms delay occurs, then the result is emitted
     expectObservable(effects.loadAllStateCities$).toBe('--a', {
       a: new StateActions.LoadCitiesFail({ error: new Error('Server Error') })
     });
   });
 });

 it('should cancel previous request when a new one action is fired', () => {
   testScheduler.run(({ cold, expectObservable }) => {
     // create the actions we are testing as a cold observable
     const action$ = cold('-a-b', {
       a: new StateActions.LoadAllStateCitiesAction({ delayMilli: 2 }),
       b: new StateActions.LoadAllStateCitiesAction({ delayMilli: 3 })
     }) as any;

     // mock the StateService to return the desired results after the specified delay
     const stateService = {
       getHttpResults: (state: string, delayMilli: number): Observable<string[]> => cold(`${delayMilli}ms (a|)`, {
         a: state === 'sc' ? ['Charleston', 'Columbia', 'Greenville'] : ['Asheville', 'Charlotte', 'Raleigh']
       })
     };

     // run the action through the effects
     const effects = new StateEffects(new Actions(action$), stateService);

     // one frame passes, then on frame 2 an action is fired.
     // after another frame, a new action is fired that cancels the first one,
     // then after a 3ms delay the result is emitted
     expectObservable(effects.loadAllStateCities$).toBe('--- 3ms b', {
       b: new StateActions.LoadCitiesSuccess({ cities: ['Charleston', 'Columbia', 'Greenville', 'Asheville', 'Charlotte', 'Raleigh'] })
     });
   });
 });

And there you have it, marble diagrams testing! Once you start writing test with the pattern, if you stick with it, the concepts should become very clear very fast. The syntax is not complicated just for the sake of complexity, but rather to ensure your observables are behaving exactly how you’re expecting them to behave. Now, with a nice little testing pattern in your back pocket, you can cover your application more thoroughly and ensure that race conditions are a thing of the past.