How to unit test RxJS Observables with ASCII diagrams
RxJS by itself is already hard. Adding test makes it much harder because asynchronous code can be very non-deterministic. RxJS has gone through couple of iteration so there is also a lot of outdated examples there. Here is 6x RxJS testing.
So what we'd like to do is:
Test asynchronous code synchronously and deterministically.
We can achieve that by faking and controlling time in our unit tests. In RxJS terms this is called time virtualization. Example: we want to progress time by 1 and then do something and then progress time for another time unit.
Interjection: We can visualize Observables with Marbles. Once you learn Marble diagrams it's very simple to look at these and know what operators in RxJS do. Explore the visual representation here: https://rxmarbles.com/. If you're interested in Observable visualization visit the Marble playground https://thinkrx.io/ .
What we'd like to achieve is actual unit testing the Observables without using Marbles.
But how do we declaratively represent values over time? You know, like using Marble diagrams but without Marble diagrams. Adding graphics in our code basically, which is not possible, or is it? Let's say it is with ASCII art.
¯\_(ツ)_/¯
The way we represent marbles is with characters. For example:
we can write as:
ab
So "a" is represented as blue marble and emits synchronously at 0 ms followed by "b" at 1ms as a yellow one.
But what if we'd like to both "a" and "b" to emit at the exact same virtual time. Then we put them in parenthesis like this:
(ab)
Now if we'd like to have "a" emit at 0 ms and "b" emit at 2 ms instead of 1 ms we need to put something between them. We denote this as:
a-b
"-" represent the line much like the line in marble diagram and it simply represents 1ms of virtual time passed.
Let's represent 10ms time passed between "a" and "b" (we need 9 dashes, each representing 1ms + "b" letter having 1ms of offset):
a------—b
What about 100ms (meaning b fires off at 100ms)? Creators of RxJS took time from CSS:
a 99ms b
It's 99ms because you need to think in time frames. So "b" (or each marble) takes up a single time frame.
Let's take a look now how do we define the end of the emit? The same way as marble diagram depicts it, with a horizontal line:
a 99ms b |
But that means "b" would fire at 100ms and our sequence would actually end at 101ms. So instead we write it like this:
a 99ms (b|)
So how would we represent a marble diagram like this (skip 0th ms, then fire 0 at 1ms and then 1 at 2ms:
-a(b|)
Subscriptions
So how do we represent a real unit test? Here is a simple example:
const input$ = interval(1);
const output$ = input$;
const expected = "-ab";
const sub = "---!";
Here the input never ends (by design). But we can't virtualize time infinitely. Here we define a subscription saying that 3 time frames will pass and then we wan't to unsubscribe ("!").
By the way adding spaces means nothing. You can add as many as you'd like anywhere so the ASCII characters align esthetically and you can read when what happens:
const input$ = interval(1);
const output$ = input$;
const expected = "-ab ";
const sub = " ---!";
TestScheduler
The TestScheduler provides the time virtualization. So we need to use that in our test code.
import { TestScheduler } from 'rxjs/testing';
const testScheduler = new TestScheduler((actual, expected) => {
// some assertion here with the test framework of your choice
});
testScheduler.run((helpers) => {
// RxJS time is virtualized here
const { expectObservable } = helpers;
const input$ = interval(1);
const output$ = input$;
const expected = "-ab ";
const sub = " ---!";
expectObservable(output$, sub).toBe(expected);
});
This is how we check if our marble diagram is what we expected it to be.
But our code will most likely not emit letters like a, b, c, ... We need real values for them. We can define those values like this:
import { TestScheduler } from 'rxjs/testing';
const testScheduler = new TestScheduler((actual, expected) => {
// some assertion here with the test framework of your choice
});
testScheduler.run((helpers) => {
// RxJS time is virtualized here
const { expectObservable } = helpers;
const input$ = interval(1);
const output$ = input$;
const expected = "-ab ";
const sub = " ---!";
expectObservable(output$, sub).toBe(expected, {
a: 1,
b: 2
});
});
Hot and Cold helpers
Let's make this slightly more realistic and define a method we'd like to test:
const outExample = () => {
return interval(5).pipe(throttleTime(100))
};
Our actual test would look now something like this:
it('testing ourExample', () => {
testScheduler.run(helpers => {
const { expectObervable } = helpers;
const output$ = outExample();
const expected = '5ms a 99ms b ';
const sub = 5ms - 99ms -!';
expectObservable(output$, sub).toBe(expected, {
a: 0,
b: 20
})
});
});
a = 0 and b = 20 because of throttling for 100ms.
What about "cold" and "hot". The cold and hot are helpers that help you create custom observables. So you could do something like this:
Now the tests will run as they did previously.
There is also a hot observable:
The carrot sign "^" is a new one here so we're saying with it that that's the point we actually want to subscribe. Therefor we missed all values before this subscription. So hot observable is pumping out values even before we subscribe.
Learn more about hot and cold observers here: https://benlesh.medium.com/hot-vs-cold-observables-f8094ed53339
Assertions for errors
a 99ms #
The has symbol "#" sign signifies an error coming in at a 100ms.
An example test for testing errors could be something like this:
it('testing errors', () => {
testScheduler.run(helpers => {
const { expectObservable, cold } = helpers;
const output$ = cold('1m #', null, new Error('expected error'));
const expected = '1m #';
expectObservable(output$).toBe(expected, null, new Error('expected error'));
});
});
If we'd be testing only if error has been thrown and we're not interested in which one, we could replace lines like so:
it('testing errors', () => {
testScheduler.run(helpers => {
const { expectObservable, cold } = helpers;
const output$ = cold('1m #');
const expected = '1m #';
expectObservable(output$).toBe(expected);
});
});
That was a simple intro to RxJS unit testing with ASCII diagrams.
Find more examples of marble testing here: https://rxjs.dev/guide/testing/marble-testing
This post has been transcribed with my own words and slightly modified for my own understanding from this youtube video: