# RxJS testen mit Marble-Diagrammen

URL: https://www.mikebild.dev/de/blog/rxjs-testen-mit-marble-diagrammen/

Ein Test für `debounceTime(2000)` gegen die echte Uhr dauert zwei Sekunden. Zwanzig solcher Tests dauern vierzig Sekunden – und trotz dieser Wartezeit schlagen sie sporadisch fehl, sobald die Maschine unter Last steht. An dieser Rechnung scheitern reaktive Testsuiten: `setTimeout`, `done`-Callbacks, ein Framework, das pro Testfall wartet. Die Suite wird langsam, sie wird flaky, und irgendwann traut sich niemand mehr, sie ernst zu nehmen.

Der `TestScheduler` rechnet stattdessen. Innerhalb von `run()` läuft eine virtuelle Uhr, in der ein Frame genau einer Millisekunde entspricht, und der komplette zeitliche Ablauf eines Streams steht als kurzer String da: `'-a-b-c|'`. Aus zwei Sekunden Wartezeit werden Mikrosekunden Rechenzeit. Wie diese 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. Das ist die gesamte Referenz:

- `-` – ein Zeit-Frame, also ein Tick der virtuellen Uhr. Innerhalb von `run()` entspricht ein Frame genau 1 ms.
- `a`–`z` (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](https://www.mikebild.dev/de/blog/rxjs-die-vier-subject-typen/) 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:

```ts
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:

```ts
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.

```ts
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` kommt bei Frame 1 an, sein Debounce-Fenster liefe also bei Frame 4 aus. Genau dann trifft aber `b` ein und verdrängt `a`, bevor dessen Emission ausgelöst wird. Für `b` beginnt das Fenster neu und endete bei Frame 7 – dort kommt `c`, dasselbe Spiel, `b` fällt ebenfalls weg. Nach `c` bei Frame 7 herrscht endlich lang genug Ruhe: Das Fenster läuft bei Frame 10 aus, das `complete` folgt bei Frame 11. Übrig bleibt also nur `c`, um drei Frames nach hinten verschoben. 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 sieht man am schnellsten, dass `map` die Werte transformiert, das Timing aber unangetastet lässt.

```mermaid
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:

```ts
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

In Code-Reviews tauchen immer dieselben drei Fehlerquellen auf. Sie stecken hinter den meisten „Warum ist mein Test rot?“-Momenten.

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. 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](https://www.mikebild.dev/de/blog/rxjs-in-node-ereignisstroeme/) die passende Fortsetzung.

## Weiterführende Quellen

- [Testing RxJS Code with Marble Diagrams](https://rxjs.dev/guide/testing/marble-testing) – die offizielle Anleitung zu Marble-Syntax und `TestScheduler.run`.
- [TestScheduler API](https://rxjs.dev/api/testing/TestScheduler) – die vollständige API-Referenz mit allen Helper-Signaturen.
