Blog

Scheduler und virtuelle Zeit testen

Injizierbare Zeit macht zeitabhängige Regeln schnell und deterministisch testbar. Daten, Isolation und externe Systeme bleiben davon unberührt.

≈ 7 Min. Lesezeit

Diesen Beitrag anhören (10 Min.)

MP3 herunterladen

Die teuerste Stelle in einer asynchronen Codebasis ist selten der Algorithmus. Sie ist die Zeit. Debounce, Retry, Timeout, Polling, „warte 250 ms und feuere dann den letzten Wert“ – solche Regeln sind fachlich einfach, aber im Test brutal. Wer sie mit echter Zeit prüft, baut Suiten, die langsam laufen, gelegentlich rot werden und niemandem mehr etwas beweisen. Der Ausweg steckt in einer Stelle, die im Code kaum auffällt: dem Scheduler, über den ein Operator seine Uhr bezieht. Steht der als Argument in der Signatur statt als Framework-Default im Hintergrund, lässt sich die Uhr im Test vorspulen, statt auf sie zu warten.

Die Reihenfolge, in der sich das erschließt, ist nicht die Reihenfolge der Dokumentation: MVVM, asynchrone Programmierung, Scheduler, Streams, Observable Pattern, Message Bus, Testing. Erst entsteht das Problem mit Zeit, dann kommt Rx als Modell dafür, und ganz am Ende steht der Scheduler als die Grenze, an der das Ganze überhaupt prüfbar wird. Dieser letzte Schritt trägt das meiste – er macht aus einer eleganten, aber schwer greifbaren Technik etwas, das man in Sekunden testen und im Team verteidigen kann.

Das eigentliche Problem ist nicht Async, sondern Warten

Reactive Programming – ob RxJS im Frontend oder ReactiveUI im Desktop-Client – modelliert asynchrone Daten als Observables mit Operatoren. Die Stärke liegt nicht in der Menge der Operatoren. Sie liegt darin, dass Zeit, Abbruch, Fehler und Transformation explizit werden. debounce, switchMap, mergeMap und combineLatest treffen jeweils eine andere Systemaussage: Wird ein alter Request abgebrochen? Dürfen Ergebnisse parallel eintreffen? Zählt nur der letzte Wert? Operatoren sind Antworten auf diese Fragen, keine Vokabelliste.

Sobald aber Zeit im Spiel ist, kippt die Testbarkeit. Ein Test für „debounce um 250 ms“ hat drei schlechte Optionen: real warten (langsam, und mit jeder solchen Regel summiert sich die Laufzeit), großzügige Sleeps setzen (nichtdeterministisch, weil sie auf langsamer CI anders takten) oder die Logik gar nicht testen (die Regel driftet unbemerkt). Alle drei kosten – und zwar dort, wo es am meisten weh tut: im täglichen Feedback-Zyklus des Teams. Eine Suite, die zehn Minuten läuft und alle paar Läufe grundlos rot ist, wird nicht mehr vertraut. Und einer Suite, der niemand vertraut, folgt niemand.

Die Idee: Zeit ist eine Abhängigkeit

Der architektonische Hebel ist unspektakulär und genau deshalb tragfähig: Zeit wird nicht aus der Laufzeit genommen, sondern hereingereicht. Ein Scheduler ist die Abstraktion, über die Operatoren Zeit beziehen – „wann wird das nächste Ereignis geplant, und relativ zu welcher Uhr“. In Produktion ist das eine echte Uhr. Im Test ist es eine virtuelle Uhr, die ich von Hand vorspule.

const result$ = input$.pipe(
  debounceTime(250, scheduler),
  distinctUntilChanged(),
  switchMap((query) => search(query))
);

Der einzige Unterschied zur naiven Variante ist das zweite Argument von debounceTime. Es sieht nach einem Detail aus, verschiebt aber die Kontrolle über Zeit vom impliziten Framework-Default zu einer expliziten, austauschbaren Abhängigkeit. Das ist dasselbe Prinzip wie Dependency Injection für eine Datenbank oder einen HTTP-Client – nur angewandt auf die eine Ressource, die sich sonst nicht mocken lässt.

Im Test wird dann nicht gewartet. Zeit wird bewegt.

scheduler.advanceBy(249);
expect(search).not.toHaveBeenCalled();
scheduler.advanceBy(1);
expect(search).toHaveBeenCalledWith("abc");

Der Test macht eine Aussage, die vorher gar nicht formulierbar war: „Bei 249 ms passiert nichts, bei genau 250 ms feuert der Aufruf.“ Er läuft in Mikrosekunden, unabhängig von der Maschine, und er dokumentiert die fachliche Regel exakt. Der Gewinn ist dabei nicht in erster Linie Tempo. Es ist die Präzision: Eine Zeitentscheidung, über die vorher nur geredet wurde, wird nachweisbar.

flowchart LR
  Input[User input] --> Debounce
  Debounce --> Distinct
  Distinct --> SwitchMap
  SwitchMap --> Result
  SwitchMap -. cancel old request .-> Cancelled

Warum das eine Architekturfrage ist, keine Testtechnik

Es wäre bequem, virtuelle Zeit als Trick der Testschicht abzulegen. Das greift zu kurz. Die Entscheidung, Zeit injizierbar zu machen, zwingt den Produktivcode, seine zeitlichen Abhängigkeiten offenzulegen. Wo vorher ein setTimeout still im Inneren einer Klasse saß, steht jetzt ein Scheduler in der Signatur. Zeit wird von einem versteckten Seiteneffekt zu einem sichtbaren Kontrakt.

Beim Debounce lautet die fachliche Regel: Das Backend soll nicht jeden Tastendruck erhalten, die Oberfläche aber zeitnah reagieren. Der Scheduler macht die gewählten 250 Millisekunden zu einer austauschbaren Eingabe. Wenn der Wert zu knapp ist, ändere ich ihn zusammen mit einem Grenztest statt durch manuelles Klicken auf einer langsamen Umgebung.

Der Trade-off ist real und gehört benannt. Ein Scheduler in der Signatur ist mehr Zeremonie als ein direkter Timer. Man muss ihn durchreichen, und Teams, die Rx neu einführen, empfinden das anfangs als Overhead. Spätestens mit der zweiten oder dritten zeitabhängigen Regel amortisiert sich der Aufwand. Vorher ist es Disziplin, danach ist es Selbstläufer. Wer den Scheduler von Beginn an mitführt, zahlt einen kleinen konstanten Preis; wer ihn nachrüsten muss, zahlt ihn als teures Refactoring quer durch die Aufrufkette.

Ein zweites Beispiel: Retry mit Backoff

Debounce ist der freundliche Fall, weil die Zeit klein und einstellig ist. Der Fall, der ohne virtuelle Zeit praktisch untestbar bleibt, ist Retry mit exponentiellem Backoff. Die fachliche Absicht: Ein flüchtiger Netzwerkfehler soll nicht sofort durchschlagen, sondern nach 1 s, dann 2 s, dann 4 s erneut versucht werden – und nach drei Versuchen endgültig scheitern.

const result$ = request$.pipe(
  retryWhen((errors) =>
    errors.pipe(
      scan((count) => count + 1, 0),
      delayWhen((count) => timer(2 ** count * 1000, scheduler)),
      take(3)
    )
  )
);

Mit echter Zeit ist der Test dieser Regel eine Zumutung: Ein einziger Durchlauf wartet sieben Sekunden, und die Grenze „nach dem dritten Versuch kommt der Fehler durch“ prüft niemand gern in einer Suite, die hundertmal am Tag läuft. Mit injizierter Zeit wird daraus ein präziser, sofortiger Test:

scheduler.advanceBy(1000);
expect(request).toHaveBeenCalledTimes(2);
scheduler.advanceBy(2000);
expect(request).toHaveBeenCalledTimes(3);
scheduler.advanceBy(4000);
expect(onError).toHaveBeenCalled();

Das ist der Moment, in dem die Entscheidung ihren Wert zeigt. Eine Regel, die im Betrieb über Ausfallsicherheit entscheidet – zu aggressives Retry überlastet ein ohnehin schwaches Backend, zu zaghaftes lässt Nutzer im Regen stehen – wird zu einer Sache von drei Zeilen, die ich anpassen und beweisen kann. Ohne virtuelle Zeit ist genau diese Regel die, die in der Praxis ungetestet bleibt und im Zwischenfall zum Rätsel wird.

Die zweite Grenze: Besitz statt globaler Streams

Zeit testbar zu machen löst nur die eine Hälfte. Die andere ist die Subscription-Grenze. In UI- und Messaging-Systemen entscheidet vor allem, wer einen Stream besitzt und wann er endet. Ein Stream, der überall abonniert und nirgends beendet wird, ist nur globaler Zustand mit einem anderen Namen – mit denselben Symptomen: Speicherlecks, doppelte Nebenwirkungen, schwer erklärbares Verhalten unter Last.

Gute Streams haben deshalb drei Eigenschaften, die ich im Review aktiv suche: einen klaren Besitzer, eine definierte Lebensdauer und benannte Nebenwirkungen. Das ist keine Stiltugend, sondern die Voraussetzung dafür, dass Tests überhaupt aussagekräftig bleiben. Ein Test mit virtueller Zeit ist wertlos, wenn die Subscription im echten System länger lebt als gedacht und in Produktion doppelt feuert. Zeit-Kontrolle und Lebensdauer-Kontrolle sind zwei Seiten derselben Disziplin.

Commands statt Button-Handler

Denselben Gedanken – Zustand und Lebensdauer explizit machen – trägt ReactiveUI in die Interaktion. ReactiveCommands modellieren eine UI-Aktion als ausführbare Operation mit Zustand: kann ausgeführt werden, läuft gerade, liefert Ergebnis, liefert Fehler. Der Button trägt damit keine Business-Logik mehr; er zeigt nur noch den Zustand eines Commands an.

flowchart LR
  Button --> Command
  Command --> CanExecute
  Command --> IsExecuting
  Command --> Result
  Command --> Error
  Result --> ViewModel
  Error --> ErrorPolicy

Der Nutzer klickt nicht „eine Funktion“. Er startet eine Operation mit Lebensdauer – und die lässt sich, wieder über den Scheduler, deterministisch testen: Ist CanExecute während der Ausführung false? Wird der Fehlerpfad in die ErrorPolicy geleitet statt in einen verschluckten Exception-Handler? Das sind genau die Fragen, die im Demo-Modus nie auffallen und im Betrieb teuer werden. ReactiveCommands, Dynamic Data, Bind/OneWayBind und ein zentraler ExceptionHandler zeigen dieselbe Richtung: Reactive Programming heißt nicht, Operatorlisten zu lernen, sondern UI-Lebensdauer und Fehlerpfade zu modellieren.

Die methodische Konsequenz für Reviews

Wenn Zeit eine Abhängigkeit ist, ändert sich, worauf ich im Review schaue. Ich frage nicht mehr „gibt es einen Test“, sondern „welche Zeitentscheidung beweist dieser Test“. Feuert Debounce bei genau der Schwelle? Bricht switchMap den alten Request nachweislich ab? Läuft der Retry mit dem erwarteten Backoff, geprüft über eine vorgespulte Uhr statt über reales Warten?

Und ich prüfe den Fehlerpfad, nicht nur den glücklichen. Viele Systeme sehen im Demo sauber aus. Interessant wird es bei Wiederholung, Teilfehlern, Netzwerkproblemen und menschlicher Korrektur. Ein häufiger Denkfehler bleibt, das Werkzeug mit der Lösung zu verwechseln: Rx ist kein Fachmodell, ein Scheduler ist kein Test, und ein grüner glücklicher Pfad ist kein Beweis. Die Werkzeuge können gute Grenzen ausdrücken, aber sie erzeugen sie nicht von selbst.

Was der Aufwand einbringt

Der Nutzen lässt sich an zwei Dingen prüfen: Zeitabhängige Fälle laufen mit virtueller Zeit in Millisekunden statt in realen Warteintervallen, und Grenzwerte wie „bei 249 nichts, bei 250 feuern“ werden exakt beschrieben. Das beseitigt zeitbasierte Flakiness. Ein roter Lauf kann trotzdem aus Daten, geteilter Isolation oder externen Systemen stammen; der Scheduler löst diese Ursachen nicht. Relevant sind deshalb die Laufzeit genau dieser Tests und ihr Anteil an den instabilen CI-Läufen.

Schluss

Reactive Code wird nicht besser, indem man mit Operatorlisten beginnt. Er wird verständlicher, wenn die zeitliche Semantik explizit wird. Zeit vom versteckten Default zur injizierten Abhängigkeit zu machen kostet etwas Zeremonie, erlaubt aber schnelle Tests für Debounce, Timeout und Backoff. Mehr verspricht der Scheduler nicht.

Wie fandest du diesen Beitrag?

Kommentare