npm install -D @hirez_io/observer-spy
or
yarn add -D @hirez_io/observer-spy
Testing RxJS observables is unusually hard, especially when testing advanced use cases. This library:
- Makes it easy to understand
- Reduces the complexity
- Makes testing advanced observables easy
Marble tests offer very powerful testing solutions. Unfortunately marble tests are conceptually very complicated to learn and to reason about. Marble tests are not suitable for day-to-day testing within most developer testing.
Why are Marble Tests difficult?
You need to learn and understand cold
and hot
observables, schedulers
and to learn a new syntax just to test a simple observable chain. More complex observable chains tests gets even harder to read.
The Observer-Spy library was created to present a viable alternative to Marble Tests. An alternative that is cleaner, easier to understand, and super-easy to use. Observer-Spy makes RxJS testing easy! 👀💪
Why are Observer Spy(s) better?
You generally want to test the outcome of your action instead of implementation details [like how many frames were between each value].
For most production apps use cases, testing the received values is the requirement. Most of the time, it()
should be sufficient to prove whether the expected RxJS stream outcome is valid or not.
In order to test observables, you can use the spyOn(<observable>, completionCallback)
function to auto-subscribe to the stream and "record" all the values that stream observable emits. spyOn()
even returns a dispose
function to easily unsubscribe from the stream.
You can also spy on the
error
orcomplete
states of the observer.
const [spy, dispose] = spyOn(<observable>);
afterEach(() => {
SpyUtils.disposeAll();
});
Now developers can easily batch unsubscribe all spys in their tests: using SpyUtils.disposeAll()
.
import { Observable, from } from 'rxjs';
import { spyOn, SpyUtils } from '@hirez_io/observer-spy';
describe('Using spyOn() features', () => {
afterEach(() => SpyUtils.disposeAll()); // unsubscribe all spys
it('should spy on Observable values', () => {
const list = ['1st', '2nd', '3rd'];
const source$: Observable<string> = from(list);
const optionalCallback = (spy1) => {
expect(spy1.state.called.complete).toBe(true);
};
const [spy, dispose, , values] = spyOn(source$, optionalCallback);
expect(spy.values).toEqual(list);
expect(spy.values.length).toEqual(list.length);
expect(spy.readFirst()).toEqual('1st');
expect(spy.values(1)).toEqual(list[1]);
expect(spy.readLast()).toEqual('3rd');
expect(spy.state.called.next).toBe(true);
expect(spy.state.called.complete).toBe(true);
dispose(); // can directly dispose of spy connection
});
it('should spy on Observable errors', () => {
const stream$ = throwError('FAKE ERROR');
const [spy] = spyOn(stream$);
expect(spy.state.called.error).toBe(true);
expect(spy.state.errorValue).toEqual('FAKE ERROR');
});
});
RxJS - without delaying operators or async execution contexts - will run synchronously. This is the simplest use case; where our it()
does not need any special asynchronous plugins.
it('should set `called.next` to true', () => {
const [spy, disconnect] = spyOn(from(['first', 'second', 'third']));
expect(spy.values.length).toBe(3);
});
General Rules:
- If you are using Promise(s), just use the test callback
done()
. - If you are using any RxJS operators like
delay
ortimeout
in your tests, you should use thefakeTime
utility function and callflush()
to simulate the passage of time;
Since Promise(s) are MicroTasks, we should consider them to resolve asynchronously.
For code using either Promise(s) without timeouts or intervals, just use it('should ....', (done) => {});
.
Developers will call the done()
function inside the optional onComplete()
callback option in the spyOn(<observable>, <callback>)
function:
// ... other imports
import { spyOn } from '@hirez_io/observer-spy';
it('should work with promises', (done) => {
const EVENT = 'fake data';
const fakeService = { getData: () => Promise.resolve(EVENT) };
const http$ = of('').pipe(switchMap(() => fakeService.getData()));
spyOn(http$, (spy) => {
expect(spy.readLast()).toEqual(EVENT);
done();
});
});
RxJS code that has time-based logic (e.g using timeouts / intervals / animations) will emit asynchronously. And RxJS streams constructed from HTTP REST calls will emit asynchronously. fakeTime()
is a a custom utility function perfect for most of these use-cases.
fakeTime()
does the following things:
- Changes the RxJS
AsyncScheduler
delegate to useVirtualTimeScheduler
and use "virtual time". - Passes a
flush
function you can call toflush()
when you want to virtually pass time forward. - Works well with
done
if you pass it as the second parameter (instead of the first)
Now - with our virtual time =- your tests are easy:
import { from } from 'rxjs';
import { spyOn, SpyUtils, fakeTime } from '@hirez_io/observer-spy';
describe('spyOn with fakeTime', () => {
afterEach(() => SpyUtils.disposeAll());
it(
'should handle delays with a virtual scheduler',
fakeTime((flush) => {
const VALUES = ['first', 'second', 'third'];
const delayed$ = from(VALUES).pipe(delay(20000));
const [spy] = spyOn(delayed$);
flush();
expect(spy.values).toEqual(VALUES);
})
);
it(
'should handle `done()` functionality as well',
fakeTime((flush, done) => {
const VALUES = ['first', 'second', 'third'];
const delayed$ = from(VALUES).pipe(delay(20000));
const [spy1] = spyOn(delayed$, (spy) => {
expect(spy1.values).toEqual(spy.values);
done();
});
flush();
})
);
});
With Angular, you can control time in a much more versatile way. Just use fakeAsync
(and tick
if you need it):
// ... other imports
import { spyOn } from '@hirez_io/observer-spy';
import { fakeAsync, tick } from '@angular/core/testing';
it('should test Angular code with delay', fakeAsync(() => {
const EVENT = 'fake value';
const delayed$ = of(EVENT).pipe(delay(1000));
const [spy, dispose] = spyOn(delayed$);
tick(1000);
dispose();
expect(spy.readLast()).toEqual(EVENT);
}));
Asynchronous REST calls (using axios, http, fetch, etc.) should not be tested in a unit / micro test... Test those in an integration test! 😜
In the online course "Testing In Action", @shai_reznik will go over all the differences and show you how to use this library to test stuff like switchMap
, interval
etc...