DEV Community

Sukhpinder Singh
Sukhpinder Singh

Posted on

The Test That Slept and the Test That Never Woke Up

My retry tests took seven seconds each. Not because they did seven seconds of work — because they did seven seconds of nothing, waiting out real backoff delays with a real clock. So I did the textbook thing: injected TimeProvider, swapped in FakeTimeProvider, felt smug for about ninety seconds. Then my shiny new fast test didn't come back slow. It didn't come back at all.

This is the story of both tests: the one that slept, and the one that never woke up.

Seven seconds of nothing

The code under test is a bog-standard exponential backoff helper. Three transient failures, then success, with 1s, 2s and 4s waits in between. The honest way to test it against the system clock looks like this:

var retry = new TransientRetry(TimeProvider.System, maxAttempts: 4, baseDelay: TimeSpan.FromSeconds(1));
var calls = 0;

var result = await retry.ExecuteAsync(() =>
    ++calls < 4 ? throw new TimeoutException("flaky dependency")
                : Task.FromResult("ok"));

Assert.Equal(4, retry.Attempts);
// 1s + 2s + 4s of genuine wall-clock waiting:
Assert.True(sw.Elapsed >= TimeSpan.FromSeconds(7));
Enter fullscreen mode Exit fullscreen mode
Passed Succeeds_after_three_transient_failures_real_clock [7 s]
Enter fullscreen mode Exit fullscreen mode

Seven seconds, every run, forever. The test is correct and I resent it. Multiply by a give-up-path test, a jitter test, a cancellation test, and one small retry class quietly bills your CI half a minute per run. That's the sleep tax, and most suites pay it in a dozen places.

Owning the clock

Since .NET 8 the fix has been in the box. TimeProvider is the abstraction, and the only change my retry helper needed was accepting one and handing it to Task.Delay:

public sealed class TransientRetry(TimeProvider clock, int maxAttempts, TimeSpan baseDelay)
{
    public int Attempts { get; private set; }

    public async Task<T> ExecuteAsync<T>(Func<Task<T>> operation, CancellationToken ct = default)
    {
        var delay = baseDelay;
        while (true)
        {
            Attempts++;
            try { return await operation().ConfigureAwait(false); }
            catch (Exception) when (Attempts < maxAttempts)
            {
                await Task.Delay(delay, clock, ct).ConfigureAwait(false);
                delay *= 2;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Production passes TimeProvider.System. Tests pass FakeTimeProvider from the Microsoft.Extensions.TimeProvider.Testing package and move time by hand:

var task = retry.ExecuteAsync(() =>
    ++calls < 4 ? throw new TimeoutException("flaky dependency")
                : Task.FromResult("ok"));

Assert.False(task.IsCompleted);          // parked on the 1s backoff
fake.Advance(TimeSpan.FromSeconds(1));   // attempt 2 fails, parks on 2s
fake.Advance(TimeSpan.FromSeconds(2));   // attempt 3 fails, parks on 4s
Assert.False(task.IsCompleted);          // mid-backoff assertion. Try writing THIS with Thread.Sleep.
fake.Advance(TimeSpan.FromSeconds(4));   // attempt 4 succeeds

Assert.Equal("ok", await task);
Enter fullscreen mode Exit fullscreen mode

Seven virtual seconds, zero real ones. I ran exactly this in a console app first and it worked beautifully. Then I pasted it into the xunit project and dotnet test sat there until the hang detector shot the process.

The test that never woke up

Same code. Passes in a console app, deadlocks in xunit. I stuck a file-based trace into the test because at that point I trusted nothing, and it told the whole story:

[t5] started  calls=1  sc=AsyncTestSyncContext
[t5] after+1  calls=1  completed=False
[t5] after+2  calls=1  completed=False
[t5] after+4  calls=1  completed=False
[t9] attempt 2  sc=null      <- thread pool, AFTER all my advances
Enter fullscreen mode Exit fullscreen mode

calls never moved while I advanced the clock. Attempt 2 eventually ran on a different thread, after all three Advance calls had already happened.

Here's the mechanism. When Advance() fires the timer, the await Task.Delay continuation is supposed to run right there, synchronously. But the .NET scheduler refuses to inline a continuation onto a thread whose SynchronizationContext.Current isn't null — and xunit v2 runs every test under its AsyncTestSyncContext. Yes, even with ConfigureAwait(false): that controls where the continuation goes, not whether the completing thread may run it inline. So the continuation gets queued to the thread pool, my test thread sprints through the remaining Advance calls, and only then does attempt 2 register its 2-second delay. Virtual now is already +7s; the new timer is due at +9s; nothing will ever advance the clock again. The test sleeps forever, which is a funny outcome for a test whose entire purpose was to stop sleeping.

The console app has no synchronization context, so everything inlines and the exact same code is deterministic. That's why my repro "proved" the code was fine.

The fix is one load-bearing line at the top of the test:

SynchronizationContext.SetSynchronizationContext(null);
Enter fullscreen mode Exit fullscreen mode
Passed Same_scenario_fake_clock [36 ms]
Passed Gives_up_after_max_attempts_fake_clock [10 ms]
Enter fullscreen mode Exit fullscreen mode

From 7 seconds to 36 milliseconds, and the mid-backoff Assert.False is now a real, deterministic assertion instead of a race I'd have lost one Friday a month. (Timings are xunit's own per-test numbers from a small Linux container — I care about seconds-versus-milliseconds, not the exact digits.)

If nulling the context feels too spicy for your codebase, the alternative is advancing in a loop — while (!task.IsCompleted) { fake.Advance(step); await Task.Yield(); } — which tolerates the thread hops but gives up the crisp step-by-step assertions. I prefer the one-liner and a loud comment.

The bug you can finally write down

The part I ended up liking most isn't the speed. FakeTimeProvider also lets you set the time zone, which means the whole class of "only fails at midnight in production" bugs becomes a normal, boring test:

// 18:20 UTC on July 30 == 23:50 in Kolkata (UTC+05:30)
var fake = new FakeTimeProvider(new DateTimeOffset(2026, 7, 30, 18, 20, 0, TimeSpan.Zero));
fake.SetLocalTimeZone(TimeZoneInfo.FindSystemTimeZoneById("Asia/Kolkata"));

var quota = new DailyQuota(fake, limit: 2);
// ...exhaust the quota at 23:50 local...
fake.Advance(TimeSpan.FromMinutes(20));  // 00:10 local — but still July 30 in UTC
Assert.True(quota.TryConsume());         // resets at LOCAL midnight, as promised
Enter fullscreen mode Exit fullscreen mode

Key your "daily" reset on GetUtcNow().Date instead of GetLocalNow() and this test catches it instantly — no waiting for a user five and a half time zones away to complain that their quota comes back at 5:30 in the morning.

Two honest caveats. This only works if every delay in the code path takes the provider — one naked Task.Delay(5000) buried in a helper and your test is back to sleeping for real, with no error to tell you why. And I don't inject TimeProvider religiously: code that just stamps a log line can keep DateTime.UtcNow. My rule is that the moment logic branches on time or sleeps on it, the clock is a dependency and should come in through the front door.

Full runnable sample, deadlock explanation included: https://github.com/ssukhpinder/dev-to-code-samples/tree/main/013-timeprovider-fake-time

Grep your test folder for Task.Delay and Thread.Sleep and tell me the damage in the comments — I'll go first: mine was seven seconds for one class.

— still deadlocking things nobody asked me to

Top comments (0)