Test time-dependent code with TimeProvider

ยท 1 min read

Code that calls DateTime.UtcNow directly is hard to test. Since .NET 8, TimeProvider gives you a standard abstraction, and a fake one that you can move forward on demand.

A lot of business logic depends on the current time: tokens expire, invoices become overdue, retries wait, subscriptions renew. If that code calls DateTime.UtcNow directly, testing it means either waiting in real time or living with tests that pass or fail depending on when they run.

For years every codebase invented its own IClock or ISystemClock. Since .NET 8 there's a standard one: TimeProvider.

Use it in your code

Inject TimeProvider and ask it for the time:

public class InvoiceService(TimeProvider time)
{
    public bool IsOverdue(Invoice invoice) =>
        time.GetUtcNow() > invoice.DueDate.AddDays(30);
}

Register the real one once:

builder.Services.AddSingleton(TimeProvider.System);

Use the fake in tests

The Microsoft.Extensions.TimeProvider.Testing package includes FakeTimeProvider, which you control:

[Fact]
public void Invoice_becomes_overdue_after_30_days()
{
    var time = new FakeTimeProvider(new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero));
    var service = new InvoiceService(time);
    var invoice = new Invoice { DueDate = new DateTimeOffset(2026, 6, 1, 0, 0, 0, TimeSpan.Zero) };

    time.Advance(TimeSpan.FromDays(30));
    Assert.False(service.IsOverdue(invoice));

    time.Advance(TimeSpan.FromSeconds(1));
    Assert.True(service.IsOverdue(invoice));
}

The test runs in microseconds and gives the same result every time.

It covers delays and timers too

TimeProvider isn't just "what time is it". Timers and delays can use it as well:

await Task.Delay(TimeSpan.FromMinutes(5), time, ct);

using var timer = new PeriodicTimer(TimeSpan.FromMinutes(1), time);

With FakeTimeProvider, those waits complete when you call Advance, so you can test retry delays, polling loops and timeouts without actually waiting.

Watch for the hidden calls

Switching to TimeProvider only helps if you switch everywhere. Search the code for DateTime.Now, DateTime.UtcNow, DateTimeOffset.UtcNow and plain Task.Delay calls. A banned-API analyzer rule (Microsoft.CodeAnalysis.BannedApiAnalyzers) can stop new ones from creeping back in.

While you're there: prefer DateTimeOffset and UTC for anything stored or compared. DateTime.Now depends on the server's time zone, which is a different source of bugs.

Takeaway

Inject TimeProvider instead of reading the clock directly, register TimeProvider.System in production, and use FakeTimeProvider in tests to move time forward on demand. Time-dependent logic becomes fast and reliable to test.