De TimeProvider van .NET 8 en een enkele aanpassing in de SynchronizationContext kunnen seconden besparen op unit tests voor retry-logica die anders onnodig wachten op echte sleeps. Een test die voorheen zeven seconden duurde, is nu binnen enkele tientallen milliseconden klaar, en de wijziging kost niets extra in productie.
Waarom de vertraging ertoe doet
Veel retry-helpers maken gebruik van exponential backoff: wacht 1 seconde, dan 2 seconden, dan 4 seconden, enzovoort. In productie beschermt dit services tegen het overbelasten van een falende endpoint. In een testsuite roept dezelfde code Task.Delay aan op een echte klok, waardoor de testthread daadwerkelijk in slaap valt. Vermenigvuldig dit met tientallen tests en de continuous-integration (CI) pipeline loopt minuten uit zonder functioneel voordeel. De "sleep tax" is pure tijdverspilling.
Hoe TimeProvider werkt
.NET 8 introduceerde TimeProvider, een abstractielaag over de systeemklok. Een klasse die de huidige tijd nodig heeft of een vertraging moet inlassen, kan een TimeProvider-instantie accepteren in plaats van direct DateTime.UtcNow of Task.Delay aan te roepen. In productie geef je TimeProvider.System door, wat doorverwijst naar de echte klok. In een test lever je een FakeTimeProvider aan. De fake versie laat je de tijd handmatig vooruitzetten met Advance(TimeSpan). Intern leest Task.Delay de provider, waardoor het vooruitzetten van de fake klok onmiddellijk elke lopende vertraging voltooit.
Het idee is simpel: vervang de echte klok door een controleerbare klok, en spring vervolgens vooruit naar het punt waarop de te testen code zou zijn hervat.
De xUnit SynchronizationContext-val
Het concept werkt in een console-app, maar in xUnit veroorzaakte het een deadlock. De test riep Advance() aan terwijl de retry-helper wachtte op een vertraging. xUnit installeert zijn eigen SynchronizationContext die continuations vastlegt en deze uitvoert op de testthread. Toen Advance() de klok vooruitzette, werd de continuation in de threadpool geplaatst in plaats van op de testthread. De testthread bleef de tijd vooruitzetten, waardoor de gesimuleerde klok voorbij het moment ging waarop de volgende retry zou moeten plaatsvinden. De nieuwe timer werd ingesteld op een toekomstig punt dat nooit bereikt zou worden, omdat er geen thread meer over was om de klok opnieuw te verzetten. De test bleef onbeperkt hangen.
De oplossing in één regel
Het toevoegen van een enkele regel aan het begin van de test herstelt het verwachte gedrag:
SynchronizationContext.SetSynchronizationContext(null);
Het wissen van de aangepaste context dwingt await-continuations om op de threadpool te draaien, waar de callbacks van de fake timer kunnen worden uitgevoerd. Met die wijziging daalt dezelfde test van zeven seconden naar ongeveer 36 milliseconden.
Bredere voordelen
Naast het elimineren van onnodige sleeps, maakt een fake klok het eenvoudig om tijdzonegevoelige logica te testen. Je kunt verifiëren dat een dagelijks quotum wordt gereset op de juiste lokale middernacht, zonder te wachten tot de echte klok twaalf uur slaat. Dezelfde aanpak werkt voor elke code die vertakt op basis van de huidige tijd: injecteer de provider, vermijd verborgen aanroepen naar Thread.Sleep of Task.Delay, en je krijgt deterministische, snelle tests.
Waar je op moet letten
- Elke vertraging moet via de geïnjecteerde
TimeProviderlopen. Een verlorenThread.Sleepof een directeTask.Delayzal nog steeds de echte klok aanroepen en latentie introduceren. - Als een bibliotheek waarvan je afhankelijk bent intern
Task.Delayaanroept zonder een provider bloot te stellen, kun je de timing niet controleren zonder een meer ingrijpende shim. In dergelijke gevallen kan het voordeel beperkt zijn. - Gebruik
grepin de testmap voorTask.DelayenThread.Sleepom verborgen wachttijden op te sporen. Het refactoren van deze aanroepen om de provider te gebruiken is de enige manier om de fake klok effectief te houden.
Conclusie
Het vervangen van de systeemklok door TimeProvider en het uitschakelen van de SynchronizationContext van xUnit verandert trage retry-tests in bijna onmiddellijke controles, waardoor CI-resources vrijkomen en tijdgevoelige code gemakkelijker te verifiëren is. De inspanning is slechts enkele regels code; de beloning wordt gemeten in de seconden die per testrun worden bespaard.
