Jay Phelps, former member of the RxJS core team, recently explained how to test asynchronous code that leverages RxJS, the reactive programming library used by the Angular front-end framework for asynchronous programming. RxJS provides a testing API with a DSL to express timed sequences and lifecycle events.
RxJS provides an Observable
type that encloses a mechanism to produce a potentially unbounded sequence of values. That sequence of values can originate from a source that is external to the program (e.g., button clicks), or result from transformations applied to existing sequences. RxJS provides additional APIs, types, and methods that let developers transform sequences (e.g., operators); and express the relationship between sources and sequences (e.g., Subject
, schedulers).
Asynchronous code that leverages RxJS thus typically expresses a sequence of events as an Observable
and applies transformations to it to express some business logic or processing. The resulting observable can then be further transformed. It can alternatively be subscribed to, with the program performing effects based on the values enclosed in the observable (e.g., API calls). In that context, testing asynchronous code is often testing that a transformation function takes an input observable into an output observable that contains an expected sequence of values (e.g., a sequence of purchases is taken into a sequence of updated basket content).
Phelps reminded that when the time information of the input and output sequences is irrelevant, testing a transformation function may be simplified into testing an observed array of values against an expected array of values:
const input$ = interval(1);
const output$ = input$.pipe(toArray());
output$.subscribe(value => {
expect(value).toEqual([0, 1]);
});
The toArray
function is part of RxJS API and aggregates the values in input$
into a single array. The observable returned by toArray
has that single array as the only value in the observable sequence of values. interval(1)
is an unbounded sequence of values separated by one second. The previous example thus actually exemplifies how unbounded sequences can create edge cases. Here, the output$
observable never gets to produce its only value, as it waits for the last value of interval
to perform the aggregation — value which never comes. The existence of similar edge cases and pitfalls is a testimony that testing asynchronous code reliably is hard.
The previous testing technique consisted of comparing arrays of values, thus removing the time information from the observable’s sequence of values, and retaining only its ordering. That is not possible when the timing of the sequence is precisely what is under test.
For those cases, RxJS provides a DSL to express timed sequences and scheduling APIs that virtualize time — decoupling actual time from the time in the program under test. The DSL takes the form of marble diagrams that represent values on a time axis:
(Source: rxmarbles.com)
The previous illustration showcases the race
RxJS operator. The operator takes a series of observables (3 are represented here) and picks the one that has emitted first. The spheres represent the values that are contained in the observable and are positioned on a time axis. The caret at the end of each observable representation denotes that the observable does not produce any further value (in RxJS terminology, the observable is said to be completed).
While the graphical notation is useful for pedagogical and documentation purposes, RxJS uses a string DSL for testing purposes. The string -ab(cd) 9ms e(f|)
for instance encodes a sequence of 6 values (a, b, c, d, e, f) that are emitted at 5 different points of time. Single letters represent values; the dash symbol is a time marker meaning that no value is produced by the observable at the marked time. Values that are found at index t
of the string have been produced by the observable a fixed number of units (by default milliseconds) after the values at index t-1
. The |
symbol encodes the end of production (completion) of the observable. The 9ms
substring represents a duration of 9 milliseconds that separates the production of the preceding value from that of the next value. Space characters do not carry meaning. The DSL has additional rules and encodings. The full marble syntax is described in the RxJS documentation.
String marble diagrams thus allow specifying timed sequences. Functions transforming streams can then be tested by comparison of observed marble diagrams against expected marble diagrams. RxJS provides the TestScheduler
API and a series of helper methods for testing timed sequences:
import { TestScheduler } from 'rxjs/testing';
const testScheduler = new TestScheduler((actual, expected) => {
...
expect(actual).deepEqual(expected)
});
testScheduler.run(({expectedObservable}) => {
// Inside here, RxJS time is virtualized
const input$ = interval(1);
const output$ = input$;
const expected = '-ab ';
// `unsub` encodes subscription and unsubscription events
// Here, the test scheduler stops (`!`) processing values after three units of time (`-`) have passed
const unsub = ' ---!';
expectObservable(output$, unsub).toBe(marbles, {
a: 1,
b: 2
})
});
The TestScheduler
API allows testing asynchronous RxJS code synchronously and deterministically by virtualizing time. The TestScheduler
API, however, can only be used to test code that uses timers (e.g., delay
, debounceTime
).
The aforedescribed APIs and techniques remain low-level and may be built upon to perform more advanced testing of asynchronous processes. Testing transformations applied to concurrent processes may, for instance, require simulating the interleaving of the processes. Simulation may generate a large series of test case inputs for which it is not possible or impractical to create or maintain the matching expected outputs. Property-based testing can then be used to check the fulfillment of predicates (properties of the system under test) across the generated outputs. John Hughes, co-designer of Haskell and QuickCheck, presented at the Lambda Days conference five different strategies to identify properties of pure functions. Tomasz Kowal, tech lead at ClubCollect, provided, also at Lambda Days, an introduction to stateful property-based testing.
RxJS is a library for composing asynchronous and event-based programs by using observable sequences. It provides one core type, the Observable, satellite types (Observer, Schedulers, Subjects), and operators (e.g., map, zip) to allow handling asynchronous events as collections.