Scheduler und virtuelle Zeit testen
Wer Zeit zur injizierbaren Abhängigkeit macht, gewinnt schnelle, deterministische Tests für asynchrone Logik – und damit kürzere Feedback-Zyklen statt langsamer, flakiger Suiten.
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. Genau hier setzt eine Entscheidung an, die ich für eine der wirkungsvollsten in reaktiven Systemen halte: Zeit nicht als globale Konstante zu behandeln, sondern als Abhängigkeit, die man injiziert und im Test kontrolliert.
Der Anlass ist konkret: In einem Reactive-Programming-Workshop baue ich die Themen bewusst in dieser Reihenfolge auf – MVVM, asynchrone Programmierung, Scheduler, Streams, Observable Pattern, Message Bus, Testing. Das ist didaktisch kein Zufall. 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. Fast jedes Mal ist es dieser letzte Schritt, an dem es bei den Teilnehmern klick macht – weil er aus einer eleganten, aber schwer greifbaren Technik plötzlich etwas macht, 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, sondern 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. Genau das ist der Gewinn – nicht „schnellerer Test“, sondern ein Test, der eine präzise Zeitentscheidung überprüfbar macht.
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.
Ich betrachte solche Themen in drei Schichten: fachliche Absicht, technische Grenze und betriebliche Konsequenz. Die Absicht bei Debounce ist „schütze das Backend vor Tastendruck-Fluten und zeige nur relevante Ergebnisse“. Die Grenze ist der Scheduler – hier wird entschieden, was unabhängig getestet und getauscht werden kann. Die Konsequenz zeigt sich im Betrieb: Wenn die 250 ms zu knapp sind und Nutzer doppelte Requests auslösen, will ich das an einem Test ändern und beweisen können, nicht durch manuelles Klicken auf einer langsamen Umgebung. Injizierbare Zeit macht diese Konsequenz billig und rückbaubar.
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. Meine Erfahrung: Der Aufwand amortisiert sich mit der zweiten oder dritten zeitabhängigen Regel. 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 sind interessant, weil sie eine UI-Aktion nicht als Eventhandler behandeln, sondern als ausführbare Operation mit Zustand: kann ausgeführt werden, läuft gerade, liefert Ergebnis, liefert Fehler. Ein Button wird damit nicht zur Business-Logik, sondern zur Projektion eines Commands.
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 ist messbar, und darauf würde ich ein Team verpflichten. Erstens Laufzeit: Zeitabhängige Tests, die vorher in Sekunden liefen, laufen mit virtueller Zeit in Millisekunden. Bei einer Suite mit vielen Debounce-, Retry- und Timeout-Fällen entscheidet das über die Frage, ob Tests bei jedem Speichern laufen oder nur nachts. Zweitens Determinismus: Kein reales Warten heißt keine zeitbasierte Flakiness, und weniger Flakiness heißt, dass ein roter Lauf wieder ein echtes Signal ist. Drittens Präzision: Ein Test, der „bei 249 nichts, bei 250 feuern“ prüft, dokumentiert die fachliche Regel schärfer als jeder Kommentar – und schützt sie vor stillem Drift bei der nächsten Änderung.
Zusammengenommen sind das die drei Größen, an denen sich Developer Productivity real festmacht: kürzere Feedback-Zyklen, weniger Reibung im täglichen Lauf, höhere Vertrauenswürdigkeit der Suite. Nicht als Versprechen, sondern als Wirkung, die man an CI-Zeit und Flaky-Rate ablesen kann.
Schluss
Reactive Code wird nicht besser, indem man mit Operatorlisten beginnt. Er wird besser, wenn die zeitliche Semantik explizit wird – und der wirksamste Schritt dahin ist, Zeit vom versteckten Default zur injizierten Abhängigkeit zu machen. Das kostet ein wenig Zeremonie und bringt drei Dinge zurück, die zusammen den Unterschied zwischen Demo und Betrieb ausmachen: schnelle Tests, deterministische Ergebnisse und eine Regel, die dokumentiert und rückbaubar ist. Der eigentliche Gewinn liegt nicht im Toolnamen, sondern in der Entscheidung, die dahinter wiederverwendbar bleibt.
Kommentare