Testing Time and Background Work

Inject TimeProvider, move time with FakeTimeProvider and test periodic BackgroundService loops without sleeping or flaky timing.

Syntaxpublic sealed class Sweeper(TimeProvider time) { bool Expired(Order o) => time.GetUtcNow() - o.Placed > TimeSpan.FromMinutes(30); } var clock = new FakeTimeProvider(start); clock.Advance(TimeSpan.FromMinutes(31));

Code that reads DateTime.UtcNow or awaits Task.Delay(TimeSpan.FromMinutes(5)) can only be tested by waiting, or not at all. The result is suites with Thread.Sleep, tests that fail at midnight or on the last day of the month, and expiry rules nobody has ever seen fire. The fix is to treat time as a dependency.

TimeProvider

System.TimeProvider is the abstraction built into the runtime. Register builder.Services.AddSingleton(TimeProvider.System) and inject it wherever code reads or waits on time. It covers more than the clock:

Instead ofUse
DateTimeOffset.UtcNowtime.GetUtcNow()
Stopwatch for elapsed timetime.GetTimestamp() and time.GetElapsedTime(start)
Task.Delay(delay, ct)Task.Delay(delay, time, ct)
new PeriodicTimer(period)new PeriodicTimer(period, time)
new CancellationTokenSource(timeout)new CancellationTokenSource(timeout, time)
task.WaitAsync(timeout)task.WaitAsync(timeout, time)

Every overload you miss is a real wait hidden in code that otherwise looks testable.

FakeTimeProvider

The Microsoft.Extensions.TimeProvider.Testing package (10.11.0) provides FakeTimeProvider in the Microsoft.Extensions.Time.Testing namespace. Time stands still until the test moves it. Advance(TimeSpan) moves the clock and fires every timer that falls due in that window, in order; SetUtcNow jumps to an instant; AutoAdvanceAmount moves the clock on every read; SetLocalTimeZone makes local-time rules such as "invoices are due at 17:00 Sydney time" deterministic. Give it an explicit start instant so failure messages show meaningful dates.

Testing a BackgroundService

Split the service in two: a class with the unit of work (SweepOnce, ProcessBatchAsync) and a thin loop around it. Test the unit of work directly with a fake clock: that covers the business rules with no threads. Keep one or two tests for the loop itself, and know its three traps:

  • Timer creation races the test. ExecuteAsync runs on another thread, so the test can call Advance before the loop has created its PeriodicTimer. Time moves past nothing and the tick never comes. Wait for the timer to exist, or advance in small steps until the outcome appears.
  • Work runs after Advance returns. Advancing completes the timer's wait; the loop's continuation runs on the thread pool. Poll an observable outcome with a real-time deadline instead of asserting immediately or sleeping a fixed amount.
  • Ticks coalesce. PeriodicTimer remembers at most one missed tick. Advancing an hour on a 5-minute timer runs the work once or twice, not twelve times, exactly as in production after a slow sweep.

The production failure mode

By default an unhandled exception in ExecuteAsync stops the whole host (BackgroundServiceExceptionBehavior.StopHost). A sweep that throws on one bad row takes the API down. Catch per-item failures where they happen: log the row, park it and carry on, and write the test that proves one bad row does not end the loop. Do not wrap the whole sweep in a catch-all: systemic failures such as rejected credentials or a missing table should still escape and stop the host, because retrying them forever hides the outage. The BackgroundService lesson sorts failures into per-item, transient and systemic.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Prove an unpaid order is cancelled 30 minutes after it was placed by advancing a FakeTimeProvider, instead of a test that sleeps for half an hour.
  • Pin the 17:00 Sydney invoice cut-off so a build agent running in UTC computes the same due dates as the finance team.
  • Check that the outbox publisher keeps running after one batch times out at the broker, rather than stopping the whole API host.

More examples

The same rules tested with FakeTimeProvider

<code>GetLocalNow()</code> converts through <code>LocalTimeZone</code>, which <code>FakeTimeProvider.SetLocalTimeZone</code> controls, so the test does not depend on the build agent's time zone. The rows sit on both sides of the 17:00 cut-off and across a weekend, which is where date bugs live. On Linux the zone id is the IANA name; .NET also accepts it on Windows when ICU is available. Rules that read <code>DateTime.Now</code> directly would pass on a laptop in Sydney and fail on a UTC build agent.

Example · csharp
using Microsoft.Extensions.Time.Testing;
using Xunit;

public sealed class InvoiceDueDates(TimeProvider time)
{
    private static readonly TimeSpan CutOff = new(17, 0, 0);

    // Invoices issued after 17:00 local time count as issued the next business day; due 14 days later.
    public DateOnly DueDateForInvoiceIssuedNow()
    {
        var local = time.GetLocalNow();
        var issued = DateOnly.FromDateTime(local.DateTime);
        if (local.TimeOfDay >= CutOff) issued = issued.AddDays(1);
        while (issued.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday) issued = issued.AddDays(1);
        return issued.AddDays(14);
    }
}

public sealed class InvoiceDueDateTests
{
    private static readonly TimeZoneInfo Sydney = TimeZoneInfo.FindSystemTimeZoneById("Australia/Sydney");

    [Theory]
    [InlineData("2026-03-05T05:59:00Z", "2026-03-19")]   // 16:59 Thursday in Sydney
    [InlineData("2026-03-05T06:00:00Z", "2026-03-20")]   // 17:00 Thursday: counts as Friday
    [InlineData("2026-03-06T06:30:00Z", "2026-03-23")]   // 17:30 Friday: rolls past the weekend to Monday
    public void Cut_off_is_17_00_Sydney_time(string utcNow, string expectedDue)
    {
        var clock = new FakeTimeProvider(DateTimeOffset.Parse(utcNow, System.Globalization.CultureInfo.InvariantCulture));
        clock.SetLocalTimeZone(Sydney);

        var due = new InvoiceDueDates(clock).DueDateForInvoiceIssuedNow();

        Assert.Equal(DateOnly.Parse(expectedDue, System.Globalization.CultureInfo.InvariantCulture), due);
    }

    [Fact]
    public void Advancing_the_clock_moves_local_time_too()
    {
        var clock = new FakeTimeProvider(new DateTimeOffset(2026, 3, 5, 5, 0, 0, TimeSpan.Zero));
        clock.SetLocalTimeZone(Sydney);
        var dueDates = new InvoiceDueDates(clock);
        var before = dueDates.DueDateForInvoiceIssuedNow();

        clock.Advance(TimeSpan.FromHours(1));

        Assert.Equal(before.AddDays(1), dueDates.DueDateForInvoiceIssuedNow());
    }
}

Testing retries with backoff without waiting

The production code waits 2, 10 and 30 seconds; the test finishes in milliseconds because each <code>Task.Delay</code> is a timer on the fake clock. Advancing in small steps until the task completes avoids the race where the test advances before the next delay has been scheduled. The upper bound on the loop turns a bug (a delay that never fires) into a failed assertion instead of an endless test. Drop the provider argument from <code>Task.Delay</code> and this test takes 42 real seconds.

Example · csharp
using Microsoft.Extensions.Time.Testing;
using Xunit;

public sealed class PaymentCapture(IPaymentApi api, TimeProvider time)
{
    private static readonly TimeSpan[] Backoff = [TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30)];

    public async Task<bool> CaptureAsync(string paymentId, CancellationToken ct)
    {
        for (var attempt = 0; ; attempt++)
        {
            if (await api.TryCaptureAsync(paymentId, ct)) return true;
            if (attempt == Backoff.Length) return false;
            await Task.Delay(Backoff[attempt], time, ct);   // the provider overload, so tests control the wait
        }
    }
}

public interface IPaymentApi { Task<bool> TryCaptureAsync(string paymentId, CancellationToken ct); }

public sealed class PaymentCaptureTests
{
    private sealed class FailsTimes(int failures) : IPaymentApi
    {
        public int Calls;
        public Task<bool> TryCaptureAsync(string paymentId, CancellationToken ct) =>
            Task.FromResult(Interlocked.Increment(ref Calls) > failures);
    }

    [Fact]
    public async Task Gives_up_after_three_retries_spread_over_42_seconds()
    {
        var clock = new FakeTimeProvider(new DateTimeOffset(2026, 3, 2, 9, 0, 0, TimeSpan.Zero));
        var api = new FailsTimes(failures: 10);
        var capture = new PaymentCapture(api, clock).CaptureAsync("pay_8Kd2", TestContext.Current.CancellationToken);

        // Advance one second at a time until the operation finishes; no real waiting happens.
        while (!capture.IsCompleted && clock.GetUtcNow() < new DateTimeOffset(2026, 3, 2, 9, 5, 0, TimeSpan.Zero))
        {
            clock.Advance(TimeSpan.FromSeconds(1));
            await Task.Yield();
        }

        Assert.False(await capture);
        Assert.Equal(4, api.Calls);
        Assert.True(clock.GetUtcNow() >= new DateTimeOffset(2026, 3, 2, 9, 0, 42, TimeSpan.Zero));
    }
}

A loop that survives a transient failure

The first batch throws a timeout, the second a <code>TaskCanceledException</code> that the stopping token did not cause, the third succeeds, and the test asserts the loop is still alive by checking <code>ExecuteTask</code>, the task <code>BackgroundService</code> exposes for exactly this. The real-time deadline uses <code>DateTime.UtcNow</code> on purpose: it bounds how long the test may run, independent of the fake clock. The catch is limited to transient failures: the second test proves rejected credentials still fault <code>ExecuteTask</code>, which under the default <code>StopHost</code> stops the host so the restart and the alert happen instead of a silent retry loop.

Example · csharp
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
using Xunit;

public interface IOutboxBatch { Task<int> PublishPendingAsync(CancellationToken ct); }

public sealed class OutboxPublisher(IOutboxBatch batch, TimeProvider time, ILogger<OutboxPublisher> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(10), time);
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            try
            {
                await batch.PublishPendingAsync(stoppingToken);
            }
            catch (Exception ex) when (IsTransient(ex) && !stoppingToken.IsCancellationRequested)
            {
                // A client timeout (TaskCanceledException) the stopping token did not cause is transient too.
                // Anything else, such as rejected broker credentials, escapes and stops the host.
                logger.LogWarning(ex, "Outbox batch failed; retrying on the next tick");
            }
        }
    }

    private static bool IsTransient(Exception ex) =>
        ex is TimeoutException or HttpRequestException or OperationCanceledException;
}

public sealed class OutboxPublisherTests
{
    private sealed class FlakyBatch : IOutboxBatch
    {
        public int Calls;
        public Task<int> PublishPendingAsync(CancellationToken ct) => Interlocked.Increment(ref Calls) switch
        {
            1 => throw new TimeoutException("broker did not confirm"),
            2 => throw new TaskCanceledException("HttpClient.Timeout of 100 seconds elapsed"),
            _ => Task.FromResult(3),
        };
    }

    [Fact]
    public async Task Keeps_publishing_after_a_failed_batch()
    {
        var ct = TestContext.Current.CancellationToken;
        var clock = new FakeTimeProvider();
        var batch = new FlakyBatch();
        using var publisher = new OutboxPublisher(batch, clock, NullLogger<OutboxPublisher>.Instance);
        await publisher.StartAsync(ct);

        var deadline = DateTime.UtcNow.AddSeconds(5);
        while (Volatile.Read(ref batch.Calls) < 3 && DateTime.UtcNow < deadline)
        {
            clock.Advance(TimeSpan.FromSeconds(10));
            await Task.Delay(10, ct);
        }

        Assert.True(batch.Calls >= 3, $"expected a third batch, saw {batch.Calls}");
        Assert.False(publisher.ExecuteTask!.IsFaulted);
        await publisher.StopAsync(ct);
    }

    private sealed class RejectedCredentials : IOutboxBatch
    {
        public Task<int> PublishPendingAsync(CancellationToken ct) =>
            throw new UnauthorizedAccessException("broker rejected the credentials");
    }

    [Fact]
    public async Task Rejected_credentials_end_the_loop()
    {
        var clock = new FakeTimeProvider();
        using var publisher = new OutboxPublisher(new RejectedCredentials(), clock, NullLogger<OutboxPublisher>.Instance);
        await publisher.StartAsync(TestContext.Current.CancellationToken);

        var deadline = DateTime.UtcNow.AddSeconds(5);
        while (!publisher.ExecuteTask!.IsCompleted && DateTime.UtcNow < deadline)
        {
            clock.Advance(TimeSpan.FromSeconds(10));
            await Task.Delay(10, TestContext.Current.CancellationToken);
        }

        Assert.True(publisher.ExecuteTask.IsFaulted);   // under StopHost this stops the host and alerts fire
    }
}

Discussion

  • Be the first to comment on this lesson.