The problem
I’ve found two main approaches when unit testing observables behavior in javascript:
- the marbles testing framework for comparing an observable to a mocked observable
- checking the observable directly controlling time with the Zone.js
fakeAsync()
. Check this great article from Victor Savkin aboutfakeAsync()
.
The Zone.js fakeAsync()
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 TestScheduler
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 matchObservable()
.
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 tick(10000)
. It is necessary to make the time pass for the observer timely behavior to happen (as in interval(1000)
). Also, the resolving or rejection of the promise affects the local variable matchResult
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 tick()
, use flush()
or flushMicrotasks()
to advance the asynchronous pending tasks.
The matchObservable function
The matchObservable()
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 matchObservable()
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 unsubscribe()
is only made on the next tick (accomplished with setTimeout()
because, in some circumstances, some subscription handlers are run before the obs$.subscribe()
returns and stores the value on the local variable subs
. 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 finalize()
is called.
Also, when resolving or rejecting, the local variable expectedStep
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 expectComplete
and expectError
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!