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 | getParams(): Observable<Coordinate> { |
Subscribing to the params, and changing the view when we receive a value:
1 | ngOnInit(): void { |
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 | test('ngOnInit should call setObject with query 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 timea
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 | const testScheduler = new TestScheduler((actual, expected) => { |
Test Scheduler provides some helpers, in our case, we will use:
- A
cold
observable - A
flush
to push through the values
1 | test('ngOnInit should call setObject with query params (using marbles)', () => { |
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!