.NET 8’s TimeProvider und ein einziger Tweak am SynchronizationContext lassen sich Sekunden bei Unit-Tests für Retry-Logik einsparen, die sonst durch echte Sleep-Zeiten blockiert werden. Ein Test, der früher sieben Sekunden dauerte, ist nun in wenigen Dutzend Millisekunden fertig – und die Änderung verursacht in der Produktion keinerlei Zusatzkosten.
Warum die Verzögerung ein Problem ist
Viele Retry-Helper nutzen exponentielles Backoff: erst 1 Sekunde warten, dann 2 Sekunden, dann 4 Sekunden und so weiter. In der Produktion schützt dies Dienste davor, einen fehlerhaften Endpunkt zu bombardieren. In einer Testsuite ruft derselbe Code Task.Delay auf einer echten Uhr auf, sodass der Test-Thread tatsächlich schläft. Multipliziert man das mit Dutzenden von Tests, bläht sich die Continuous-Integration (CI)-Pipeline um Minuten auf, ohne dass ein funktionaler Nutzen entsteht. Diese „Sleep-Steuer“ ist reine Zeitverschwendung.
Wie der TimeProvider funktioniert
.NET 8 hat den TimeProvider eingeführt, eine Abstraktion über die Systemuhr. Eine Klasse, die die aktuelle Zeit benötigt oder eine Verzögerung ausführen muss, kann eine TimeProvider-Instanz entgegennehmen, anstatt DateTime.UtcNow oder Task.Delay direkt aufzurufen. In der Produktion übergibt man TimeProvider.System, was an die echte Uhr weitergeleitet wird. In einem Test stellt man einen FakeTimeProvider bereit. Der Fake-Provider ermöglicht es, die Zeit manuell mit Advance(TimeSpan) voranzutreiben. Intern liest Task.Delay den Provider aus, sodass das Vorwärtsbewegen der Fake-Uhr alle ausstehenden Verzögerungen sofort erfüllt.
Die Idee ist simpel: Ersetzen Sie die echte Uhr durch eine steuerbare, und springen Sie dann vorwärts zu dem Punkt, an dem der zu testende Code fortgesetzt worden wäre.
Die xUnit SynchronizationContext-Falle
Das Konzept funktioniert in einer Konsolenanwendung, aber in xUnit verursachte es einen Deadlock. Der Test rief Advance() auf, während der Retry-Helper auf eine Verzögerung wartete. xUnit installiert seinen eigenen SynchronizationContext, der Continuations erfasst und auf dem Test-Thread ausführt. Als Advance() die Uhr vorwärts bewegte, wurde die Continuation in den Thread-Pool statt auf den Test-Thread eingereiht. Der Test-Thread trieb die Zeit immer weiter voran und schob die simulierte Uhr über den Zeitpunkt hinaus, an dem der nächste Retry hätte ausgelöst werden sollen. Der neue Timer wurde auf einen zukünftigen Zeitpunkt gesetzt, der nie erreicht werden konnte, da kein Thread mehr übrig war, um die Uhr erneut zu bewegen. Der Test hing unendlich lange fest.
Der Einzeiler-Fix
Das Hinzufügen einer einzigen Zeile zu Beginn des Tests stellt das erwartete Verhalten wieder her:
SynchronizationContext.SetSynchronizationContext(null);
Das Leeren des benutzerdefinierten Kontextes erzwingt, dass await-Continuations im Thread-Pool ausgeführt werden, wo die Callbacks des Fake-Timers ausgeführt werden können. Mit dieser Änderung sinkt die Laufzeit desselben Tests von sieben Sekunden auf etwa 36 Millisekunden.
Weitere Vorteile
Neben der Eliminierung von Leerlaufzeiten macht eine Fake-Uhr es einfach, zeitzonenabhängige Logik zu testen. Sie können verifizieren, dass ein tägliches Kontingent zur korrekten lokalen Mitternacht zurückgesetzt wird, ohne darauf zu warten, dass die echte Uhr Mitternacht schlägt. Derselbe Ansatz funktioniert für jeden Code, der basierend auf der aktuellen Zeit verzweigt: Injizieren Sie den Provider, vermeiden Sie versteckte Aufrufe von Thread.Sleep oder Task.Delay, und Sie erhalten deterministische, schnelle Tests.
Worauf zu achten ist
- Jede Verzögerung muss über den injizierten
TimeProviderlaufen. Ein einzelnesThread.Sleepoder ein direktesTask.Delaywird immer noch die echte Uhr aufrufen und Latenz einführen. - Wenn eine Bibliothek, von der Sie abhängen, intern
Task.Delayaufruft, ohne einen Provider bereitzustellen, können Sie deren Timing nicht ohne einen invasiveren Shim steuern. In solchen Fällen kann der Nutzen begrenzt sein. - Durchsuchen Sie den Testordner mit
grepnachTask.DelayundThread.Sleep, um versteckte Wartezeiten zu finden. Das Refactoring dieser Aufrufe zur Nutzung des Providers ist der einzige Weg, um die Fake-Uhr effektiv zu halten.
Fazit
Das Ersetzen der Systemuhr durch den TimeProvider und das Deaktivieren des SynchronizationContext von xUnit verwandelt träge Retry-Tests in nahezu sofortige Prüfungen, schont CI-Ressourcen und macht zeitabhängigen Code leichter verifizierbar. Der Aufwand beträgt nur wenige Zeilen Code; der Nutzen bemisst sich in den pro Testlauf gesparten Sekunden.
