Unit Testing Observables with Marbles

A few months back I was developing some new UI improvements on the UKHO’s Seabed Mapping service. One of those improvements was adding the Lat/Long/Zoom of the current map position to the URL query, and moving to that position on load. Much like Google Maps, this allowed our users to share and bookmark specific locations.

The solution was to use Angular Router to alter the current query when the map moved, and we used Angular’s Route to get those query parameters when the component loaded.

Getting the params:

1
2
3
4
5
6
7
getParams(): Observable<Coordinate> {
return this.route.queryParams.pipe(
map((p) => {
return new Coordinate(p.x, p.y, p.z);
})
);
}

Subscribing to the params, and changing the view when we receive a value:

1
2
3
4
5
ngOnInit(): void {
this.getParams().subscribe((params) => {
this.setObject({ x: 'x', y: 'y', z: 'z' });
});
}

This solution is simple, but needs to be properly unit tested.

Testing Observables

I start by writing a simple test – I need to test whether the code is calling setObject properly:

1
2
3
4
5
6
7
8
9
test('ngOnInit should call setObject with query params', () => {
mockRoute.queryParams = of({ x: 'x', y: 'y', z: 'z' });
const setSpy = jest.spyOn(component, 'setObject');
component.ngOnInit();

component.getParams().subscribe((params: Params) => {
expect(setSpy).toHaveBeenCalledWith(params);
});
});

I mock what the Route’s queryParams will return, I spy on the setObject method and I run ngOnInit() to subscribe to the Observable. I then subscribe to getParams() and assert that the spy has been called properly.

The test passes! All is well..

False Positives

How is this test passing? There is nothing actually updating the queryParams to push any values through, and is the test runner even waiting for the event at all?

The answer is no. Any expectation would pass in that subscription block as the test runner isn’t even waiting to run it, the test is just ending.

Marble Testing

RxJS provides a great solution for this: Marble Testing. Marble testing provides a solution to be able to fake our asychronous code (like above) and make it synchronous, removing any need to rely on potentially flakey and out of order results from Observables.

Marble Diagrams

Marble diagrams are a visual representation of an Observable’s values over a timeline. They are represented as a string and passed to either hot or cold observables. Cold observables time begins at the start of the string, hot observables time begins at a ^ subscription point.

Syntax examples:

  • - a passage of time
  • a all characters (a,b,c…) represent a value being emitted
  • ^ subscription point for hot observables
  • # an error which terminates the observable
  • | a successful completion
  • () a group of values emitted at the same time

a: Immediately emit value

--a: Waits two frames and emits a value

-a-b--c: Waits a frame, emits a, waits a frame, emits b, waits two frames, emits c.

a--^b: A hot observable will immediately emit b.

Faking Observables

Marble testing utilises a Test Scheduler to allow us to manipulate time and choose when an observable emits a value. A Scheduler must be set up by asserting two objects are equal with whatever testing framework you are using:

1
2
3
const testScheduler = new TestScheduler((actual, expected) => {
expect(expected).toBe(actual);
});

Test Scheduler provides some helpers, in our case, we will use:

  • A cold observable
  • A flush to push through the values
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
test('ngOnInit should call setObject with query params (using marbles)', () => {
const setSpy = jest.spyOn(component, 'setObject');
testScheduler.run(helpers => {
const { cold, flush } = helpers;

const observable = cold < Params > ('a', { a: { x: 'x', y: 'y', z: 'z' } });

mockRoute.queryParams = observable;
component.ngOnInit();

component.getParams().subscribe(params => {
expect(setSpy).toHaveBeenCalledWith({ x: 'x', y: 'y', z: 'z' });
});

flush();
});
});

Now, this isn’t much different from our previous test other than the testScheduler and the marble diagram.

1
const observable = cold < Params > ('a', { a: { x: 'x', y: 'y', z: 'z' } });

This line is “faking” our observable with a cold observable. The 'a' input is a marble diagram. This basically says: when the observable runs, return ‘a’. The value of ‘a’ is provided as the second parameter – these are our query params.

We now set queryParams to be the cold observable:

1
mockRoute.queryParams = observable;

The rest of the test runs like before. We subscribe to the observable in ngOnInit(), and we make our assertions in the subscription. A new part is that we now run flush() at the end of the test. This causes the observable to push its current value to the observable, causing the assertion in our subscribe block to run. This means these are no longer passing as a false positive, but because they actually pass!