post

RxJS testen mit Marble-Diagrammen

Marble-Diagramme machen den zeitlichen Ablauf asynchroner Streams sichtbar. Mit TestScheduler.run und virtueller Zeit testest du RxJS-Operatoren deterministisch: kein echtes Warten, keine Flakiness.

In meinen Schulungen kippt die Stimmung an einer sehr vorhersehbaren Stelle. Alle sind begeistert von RxJS, die Operatoren fühlen sich elegant an, die Pipelines lesen sich wie ein Satz – und dann kommt die Frage: „Und wie testen wir das jetzt?“ Bei einem map ist die Antwort noch harmlos. Aber sobald debounceTime ins Spiel kommt, sobald Zeit eine Rolle spielt, greifen die meisten reflexartig zu setTimeout, done-Callbacks und einem Testframework, das plötzlich zwei Sekunden pro Testfall wartet. Die Suite wird langsam, sie wird flaky, und irgendwann traut sich niemand mehr, sie ernst zu nehmen.

Dabei gibt es für genau dieses Problem eine Antwort, die schon lange Teil von RxJS ist und die ich für eine der unterschätztesten Ideen der ganzen Bibliothek halte: Marble-Diagramme. Sie machen Zeit sichtbar. Sie verwandeln einen asynchronen Ablauf in einen kurzen, lesbaren String – und der TestScheduler sorgt dafür, dass dieser String in Millisekunden statt in Sekunden verifiziert wird. Wie die Marble-Syntax funktioniert, wie man mit TestScheduler.run echte Operatoren testet und wo die typischen Stolperfallen liegen – darum geht es hier, durchgehend auf dem Stand von RxJS 7.

Warum Zeit das eigentliche Problem ist

Ein Observable ist ein Wert im zeitlichen Verlauf – oder genauer: eine Folge von Emissionen, verteilt über eine Zeitachse, abgeschlossen durch ein complete oder abgebrochen durch einen Fehler. Bei synchronen Werten ist das Testen trivial: subscribe, sammle die Werte in ein Array, vergleiche. Aber das Interessante an RxJS ist ja gerade der zeitliche Teil. Wann feuert ein debounceTime? Verschluckt ein throttleTime die richtigen Werte? Kommt das complete vor oder nach der letzten Emission?

Sobald du das mit echter Zeit testest, hast du drei Probleme auf einmal. Der Test dauert real so lange wie das Timing es vorgibt. Er ist nichtdeterministisch, weil die reale Uhr nun einmal nicht auf die Millisekunde genau tickt. Und er zwingt dich zu asynchronem Test-Code mit done-Callbacks, bei denen ein vergessener Aufruf den Test stumm hängen lässt, bis der Timeout zuschlägt.

Marble-Testing dreht diese Logik um. Statt echter Zeit läuft eine virtuelle Uhr, die der TestScheduler kontrolliert. Zeit wird nicht abgewartet, sie wird berechnet. Und der Ablauf wird nicht in imperativem Code beschrieben, sondern als Diagramm.

Die Marble-Syntax

Ein Marble-Diagramm ist ein String, in dem jedes Zeichen eine Bedeutung auf der Zeitachse hat. Die Zeit läuft von links nach rechts, und jedes Symbol steht entweder für einen Zeitfortschritt oder für ein Ereignis im Stream. Hier ist die Referenz, die ich in Schulungen immer an die Wand projiziere:

  • - – ein Zeit-Frame, also ein Tick der virtuellen Uhr. Innerhalb von run() entspricht ein Frame genau 1 ms.
  • az (oder jedes andere Zeichen) – eine next-Emission. Der konkrete Wert wird über ein values-Objekt gemappt, etwa { a: 1, b: 2 }.
  • | – das complete-Signal, der Stream endet normal.
  • # – ein error, der Stream endet mit einem Fehler.
  • () – synchrone Gruppierung: mehrere Notifications im selben Frame, zum Beispiel (ab|).
  • ^ – der Subscription-Punkt bei hot, der sogenannte Zero-Frame. Frames davor zählen negativ.
  • ! – der Unsubscription-Punkt, relevant in Subscription-Diagrammen.
  • <n>ms / <n>s / <n>m – Kurzschreibweise für viele Frames, etwa 10ms statt zehn Bindestrichen.

Ein Detail, das gerne übersehen wird: Leerzeichen erzeugen keinen Frame. Sie dienen nur der optischen Ausrichtung und werden ignoriert. Du kannst also Input und erwarteten Output untereinander schreiben und mit Leerzeichen sauber ausrichten, ohne das Timing zu verändern – nur - und die eigentlichen Symbole zählen.

Der String '-a-b-c|' liest sich damit so: ein Frame Stille, dann Wert a, ein Frame, Wert b, ein Frame, Wert c, direkt gefolgt vom complete. Insgesamt sechs Frames, drei Werte, ein sauberer Abschluss. Genau diese Lesbarkeit ist der Punkt – du siehst das Timing, ohne es aus imperativem Code rekonstruieren zu müssen.

cold und hot

Bevor wir Tests schreiben, brauchst du die Unterscheidung zwischen cold und hot, denn sie bestimmt, wann die Zeit zu laufen beginnt.

Ein cold-Observable startet erst, wenn jemand subscribed. Die Zeitachse beginnt am Subscription-Zeitpunkt. Das ist das typische Verhalten für HTTP-Requests oder andere Datenquellen, die pro Abonnent neu loslaufen. Ein hot-Observable dagegen läuft bereits, unabhängig von Subscriptions – wie ein DOM-Event oder ein Subject, das schon feuert, während du noch gar nicht zuhörst. Hier markiert das ^ den Moment, in dem die getestete Subscription einsteigt; alles davor ist bereits passiert und liegt im negativen Frame-Bereich.

Wenn du die vier Subject-Typen und ihr Multicast-Verhalten noch einmal nachlesen willst, habe ich das in Die vier Subject-Typen in RxJS ausführlich behandelt – hot-Marbles bilden genau dieses „läuft schon“-Verhalten ab.

Der erste Test: map mit virtueller Zeit

Fangen wir mit dem einfachsten sinnvollen Fall an: einem map-Operator. Wir wollen zeigen, dass jeder eingehende Wert mit zehn multipliziert wird und Timing sowie Completion unverändert durchgereicht werden.

Zuerst der Aufbau des TestScheduler. Der Konstruktor bekommt eine Assertion-Funktion, die die Deep-Equality deines Testframeworks kapselt – hier am Beispiel von Jest, aber Jasmine oder Vitest funktionieren identisch:

import { TestScheduler } from 'rxjs/testing';
import { map, debounceTime } from 'rxjs/operators';

let testScheduler: TestScheduler;

beforeEach(() => {
  testScheduler = new TestScheduler((actual, expected) => {
    expect(actual).toEqual(expected);
  });
});

Der eigentliche Test läuft dann komplett innerhalb von testScheduler.run. Der Callback bekommt ein helpers-Objekt, aus dem wir cold und expectObservable herausziehen:

test('map multiplies each value by ten', () => {
  testScheduler.run(({ cold, expectObservable }) => {
    const source$ = cold('-a-b-c|', { a: 1, b: 2, c: 3 });

    const result$ = source$.pipe(map((x) => x * 10));

    expectObservable(result$).toBe('-a-b-c|', { a: 10, b: 20, c: 30 });
  });
});

Das Muster, das du hier siehst, wiederholt sich in jedem Marble-Test: Ist-Stream gegen Soll-Diagramm. Der Input ist ein cold-Observable mit einem Diagramm und einem values-Objekt. Die Operator-Pipeline hängt daran. Und expectObservable(...).toBe(...) beschreibt, was herauskommen soll. Das Timing des Outputs ist identisch mit dem des Inputs, weil map nichts an der Zeit ändert – nur die Werte-Zuordnung im zweiten Objekt ist eine andere.

Warum funktioniert das ohne echtes Warten? Weil run() beim Zurückkehren automatisch die virtuelle Zeit durchlaufen lässt – intern über einen flush, den du in aller Regel nicht selbst aufrufen musst. Der ganze Ablauf ist synchron abgearbeitet, sobald der Callback endet.

Der eigentliche Gewinn: debounceTime

Bei map merkst du den Vorteil noch nicht wirklich. Er wird schlagartig deutlich, sobald ein zeitbasierter Operator ins Spiel kommt. debounceTime(n) gibt einen Wert erst weiter, wenn danach n Frames lang Stille herrscht – kommt vorher ein neuer Wert, wird der alte verworfen. Das ist genau die Art von Verhalten, die mit echter Zeit zur Qual wird und mit virtueller Zeit zur Fingerübung.

Der entscheidende Punkt: Innerhalb des run()-Callbacks wird jeder zeitbasierte Operator automatisch auf den TestScheduler umgeleitet. Du musst den Scheduler nicht manuell an debounceTime durchreichen. delay, debounceTime, throttleTime, interval, timer – sie alle greifen auf die virtuelle Uhr zu, sobald sie innerhalb von run() laufen.

test('debounceTime keeps only the last value after a quiet window', () => {
  testScheduler.run(({ hot, expectObservable }) => {
    // frame:      0    5    10
    const source$ = hot('-a--b--c---|');

    const result$ = source$.pipe(debounceTime(3));

    // a is displaced by b, b by c; only c survives the 3-frame quiet window
    expectObservable(result$).toBe('----------c|');
  });
});

Rechnen wir das durch, denn genau hier passieren die meisten Fehler. a feuert bei Frame 1, sein Debounce-Fenster liefe also bei Frame 4 aus. Genau dann kommt aber b – der neue Wert trifft exakt beim Auslaufen des Fensters ein und verdrängt a, bevor dessen Emission ausgelöst wird. b feuert bei Frame 4, c kommt bei Frame 7, dasselbe Spiel, b fällt ebenfalls weg. c feuert bei Frame 7, und danach herrscht endlich lang genug Ruhe: Sein Fenster läuft bei Frame 10 aus, das complete kommt erst bei Frame 11. Also überlebt nur c, emittiert drei Frames nach seiner ursprünglichen Emission bei Frame 10. Das erwartete Diagramm bildet exakt diese Verschiebung ab.

Diese Rechnerei ist kein Nebenschauplatz, sie ist der Kern. Wenn dein erwartetes Marble auch nur um einen Frame danebenliegt, schlägt der Deep-Equal fehl. Das ist anfangs nervig und wird schnell zur Stärke: Der Test zwingt dich, das Timing wirklich zu verstehen, statt es zu erahnen.

Was in einem Frame passiert

Das folgende Diagramm zeigt den Ablauf für den map-Fall als drei parallele Zeitachsen – Input oben, Operator in der Mitte, Output unten. So sehen meine Teilnehmer am schnellsten, dass map die Werte transformiert, das Timing aber unangetastet lässt.

flowchart TB
  subgraph IN["input$ – cold('-a-b-c|')"]
    direction LR
    i0["-"] --> i1["a=1"] --> i2["-"] --> i3["b=2"] --> i4["-"] --> i5["c=3"] --> i6["|"]
  end
  subgraph OP["operator"]
    direction LR
    op["map(x =&gt; x * 10)<br/>gleiche Frames,<br/>neue Werte"]
  end
  subgraph OUT["output$ – '-a-b-c|'"]
    direction LR
    o0["-"] --> o1["a=10"] --> o2["-"] --> o3["b=20"] --> o4["-"] --> o5["c=30"] --> o6["|"]
  end
  IN --> OP --> OUT

Jedes Kästchen ist ein Frame. Die Werte wandern von oben nach unten durch den Operator, das Timing bleibt Frame für Frame erhalten. Bei einem zeitbasierten Operator wie debounceTime würde sich die untere Achse gegenüber der oberen verschieben und Werte fallen lassen – genau das, was wir eben durchgerechnet haben.

Subscription-Timing prüfen

Manchmal reicht es nicht zu wissen, welche Werte herauskommen – du willst wissen, wann und wie lange überhaupt subscribed wurde. Dafür liefern cold und hot eine .subscriptions-Property, die du gegen ein eigenes Diagramm prüfen kannst. expectSubscriptions verwendet dabei ^ für den Subscribe- und ! für den Unsubscribe-Zeitpunkt:

test('records subscription and unsubscription frames', () => {
  testScheduler.run(({ cold, expectObservable, expectSubscriptions }) => {
    const source$ = cold('-a-b-c|');
    const subscription = '^-----!';

    expectObservable(source$).toBe('-a-b-c|');
    expectSubscriptions(source$.subscriptions).toBe(subscription);
  });
});

Das ist besonders wertvoll, wenn du Operatoren wie switchMap oder takeUntil testest, bei denen der eigentliche Effekt darin besteht, dass eine innere Subscription zum richtigen Zeitpunkt abgebrochen wird. Der Werte-Stream sieht dann oft unspektakulär aus – die Wahrheit steckt im Subscription-Diagramm.

Die drei Stolperfallen

Nach vielen Schulungen kristallisieren sich immer dieselben drei Fehlerquellen heraus. Ich nenne sie hier explizit, weil sie 90 Prozent der frustrierten „Warum ist mein Test rot?“-Momente ausmachen.

Erstens: echte statt virtueller Zeit. Die automatische Umleitung der zeitbasierten Operatoren gilt ausschließlich innerhalb des run()-Callbacks. Baust du dein Observable außerhalb auf oder greifst du auf den alten Stil zurück, bei dem du den Scheduler manuell durchreichst, läuft plötzlich wieder die reale Uhr. Der Test wird langsam und flaky, und der ganze Gewinn ist dahin. Merksatz: Alles, was Timing hat, gehört in den run()-Callback.

Zweitens: die Frame-Zählung. debounceTime(20) innerhalb von run() bedeutet 20 Frames, also 20 ms. Dein erwartetes Diagramm muss diese Verzögerung exakt abbilden – ob mit 20 Bindestrichen oder mit 20ms. Ein Off-by-one bei der Frame-Zählung ist mit Abstand der häufigste Grund für einen fehlschlagenden Marble-Test. Nimm dir die Zeit, das Timing wirklich durchzurechnen, statt es zu schätzen.

Drittens: Whitespace täuscht. Leerzeichen erzeugen keinen Frame, sie richten nur optisch aus. Wenn du Input und erwarteten Output ausrichtest, achte darauf, dass beide frame-synchron sind – und dass Werte, die synchron im selben Frame feuern, mit () gruppiert werden. Ein (ab|) ist etwas völlig anderes als ab|, auch wenn es fast gleich aussieht.

Der alte Stil als Kontrast

Zur Einordnung noch ein Wort zur Geschichte, denn du wirst online auf älteren Code stoßen. Früher hat man den TestScheduler ohne run() verwendet, den Scheduler explizit an die Operatoren durchgereicht und am Ende manuell testScheduler.flush() aufgerufen. In diesem Legacy-Stil entsprach ein Frame außerdem 10 ms statt 1 ms. Das funktioniert prinzipiell noch, ist aber fehleranfälliger und umständlicher.

Der empfohlene Weg ist eindeutig testScheduler.run(helpers => …) mit der klaren Regel: ein Frame gleich 1 ms, automatische Umleitung aller zeitbasierten Operatoren, automatischer Flush am Ende. Wenn du also irgendwo testScheduler.flush() oder ein manuell durchgereichtes Scheduler-Argument siehst, ist das ein Hinweis auf alten Code – kein Muster, dem du folgen solltest.

Fazit

Marble-Testing löst das eine Problem, das reaktive Programmierung schwer testbar macht: die Zeit. Statt sie abzuwarten, machst du sie sichtbar und berechenbar. Ein kurzer String beschreibt den kompletten zeitlichen Ablauf eines Streams, der TestScheduler verifiziert ihn in virtueller Zeit, und deine Suite bleibt schnell und deterministisch – keine done-Callbacks, keine Timeouts, keine sporadischen Fehlschläge.

Der Einstieg kostet etwas Übung, vor allem beim Frame-Zählen. Aber sobald das sitzt, willst du nicht mehr zurück. Ein debounceTime-Test, der früher zwei Sekunden lief und gelegentlich rot war, läuft jetzt in Mikrosekunden und ist verlässlich. Und das erwartete Diagramm dokumentiert nebenbei das Verhalten so klar, dass es die halbe Doku ersetzt. Fang klein an – ein map, ein debounceTime – und rechne jedes Timing bewusst durch. Wenn du danach reaktive Ströme auch serverseitig einsetzt, findest du in RxJS in Node.js die passende Fortsetzung.

Weiterführende Quellen

Kommentare