Testing observable’s values when using Angular fakeAsync

Image for post
Image for post

The problem

I’ve found two main approaches when unit testing observables behavior in javascript:

The Zone.js intercepts the asynchronous javascript features allowing for control of time. So, observables need no modification and, by default, they use the default scheduler and not the required by the marbles testing framework. While it is possible to make observables to use a provided scheduler, it is somewhat cumbersome and I had a lot of problemas with that route. So I decided to test the observables directly.

Also, there were this problem with the marbles testing framework. For testing an operator is awesome and very simple, but it is not adequate to test a real observable with its timely value emissions because the comparison to a mocked observable also takes into account the time when values are emitted. And since, probably, there is no need to assert on the time between emissions, comparing the observable value emissions to an array of values is adequate.

The solution

The solution is just a function to implement a comparison between an observable and an array of values, producing a promise that resolves if there is a match or rejects if not. It has the following signature:

function matchObservable<T>(
obs$: Observable<T>,
values: Array<T>,
expectComplete: boolean = true,
expectError: boolean = false,
matcher: (actual: T, expected: T) => boolean
= (a, b) => a === b,
): Promise<void>

It compares the values the observer produces with the provided array of values. It also checks for the observable completion or error if required.

Usage

I will illustrate the usage by an example of a Jasmine test for a timer generator service. The service generates an observable that makes a countdown then completes:

let duration = 5; // in seconds
return Observable
.interval(1000)
.map(i => duration - i - 1)
.take(duration)
.startWith(duration);

The test code is simple, it tries to match the generated sequence to an array of values using .

it('should generate a timer', fakeAsync(() =>
{
const expectedValues = [5, 4, 3, 2, 1, 0];
const timer$ = service.getTimer(5);
let matchResult: string;
matchObservable(timer$, expectedValues, true)
.then(
() => matchResult = null,
(result) => matchResult = result);
tick(10000);
expect(matchResult).toBeNull();
}));

Note the . It is necessary to make the time pass for the observer timely behavior to happen (as in ). Also, the resolving or rejection of the promise affects the local variable which is used to assert the match. This makes the asynchronous code to run sequentially and the test flow becomes "flat" making compositing different assertion steps straightforward.

If the observer to test does not use any specific timing, instead of , use or to advance the asynchronous pending tasks.

The matchObservable function

The function subscribes the provided observer and, using the provided matcher, goes on comparing the observer emitted values with the values in the provided array.

export function matchObservable<T>(
obs$: Observable<T>,
values: Array<T>,
expectComplete: boolean = true,
expectError: boolean = false,
matcher: (actual: T, expected: T) => boolean
= (a, b) => a === b,
): Promise<void>
{
return new Promise<void>(matchObs);
function matchObs(
resolve: () => void,
reject: (reason: any) => void)
{
let expectedStep = 0;
const subs: Subscription =
obs$.subscribe({ next, error, complete });
return;
}
}

As with any subscription, care must be taken about if the observer is hot or cold. If it’s hot, make sure you call before the observer starts emitting the values you want to compare with the array. If it is cold, take care about the possible side-effects caused by the repeated emission of the observer values. You can read more about hot and cold observables on the article by Ben Lesh.

A subscription and the corresponding unsubscribe are made within the function. And since we’re dealing a lot with asynchronous code (the promise and the observable) special care has been taken. This is concentrated on this helper function:

function finalize(message?: string)
{
expectedStep = -1;
setTimeout(() => subs.unsubscribe(), 0);
if (message)
reject(message);
else
resolve();
}

The is only made on the next tick (accomplished with because, in some circumstances, some subscription handlers are run before the returns and stores the value on the local variable . In those cases and if this function is called on that first call of the handler, the variable subs will be null at the time is called.

Also, when resolving or rejecting, the local variable must be invalidated and this way will guard any handler to run significant code after the promise is resolved or rejected. The guard is necessary because another handler can be called in the same tick that the resolving/rejecting handler, but before the unsubscribe takes place.

“Next” handler

Using a provided matcher, the “next” handler goes on comparing the observer emitted values to the provided array of values. Rejects the promise if finding any inconsistency or resolves when finishes comparing the array of values.

function next(value)
{
if (expectedStep === -1)
return;
if (expectedStep >= values.length)
finalize('Too many values on observable: '
+ JSON.stringify(value));
else
{
if (matcher(value, values[expectedStep]) === false)
finalize('Values are expected to match: '
+ JSON.stringify(value)
+ ' and ' + JSON.stringify(values[expectedStep]));
else
{
expectedStep++;
if (!expectComplete && !expectError
&& expectedStep === values.length)
finalize();
}
}
}

“Error” and “Complete” handlers

These handlers are really simple and similar, deciding to resolve or reject the promise.

Note that with and both false, the returned promise is resolved as soon as the values array is matched. The remainder of the observer behavior is not observed. If both flags are true, the function matches either a complete or an error.

function error(error)
{
if (expectedStep === -1)
return;
if (expectError && expectedStep === values.length)
finalize();
else
finalize('Observable errored unexpectedly. Error: ' + error.toString());
}
function complete()
{
if (expectedStep === -1)
return;
if (expectedStep === values.length)
finalize();
else
finalize(`Observable completed unexpectedly after ${expectedStep} value emissions. `
+ (expectedStep < values.length
? 'Missing values from observable.'
: 'Too many values on observable.'));
}

The full code is on a GitHub repository. It is also on npm.

Have fun!

PS: This article is also published on my blog.

Written by

Frontend developer. Lego crazy. Photography lover. Always learning. Remote worker.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store