Time-related code is usually the root cause of flaky tests. DateTime.Now checks, Thread.Sleep in unit tests, timers that fire a bit too early or too late. .NET 8 introduced TimeProvider, a built-in abstraction for clocks and timers. It’s also available for older targets via a NuGet package. With it, you can inject “time” the same way you inject loggers or random number generators, and your tests can fast-forward the clock instead of waiting in real time.

What TimeProvider gives you

  • GetUtcNow() / GetLocalNow() → current time as DateTimeOffset.
  • High-res timestamps: GetTimestamp() and GetElapsedTime(...) (think Stopwatch, but injectable).
  • Timers: CreateTimer(...) returns an ITimer bound to the provider.
  • Overloads on common APIs to pass a provider, like Task.Delay(...) and Task.WaitAsync(...).

Registering and injecting

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton(TimeProvider.System);

// Your services can ask for TimeProvider in their constructors
builder.Services.AddSingleton<TokenCache>();
builder.Services.AddHostedService<HeartbeatWorker>();

var app = builder.Build();
app.Run();

For older targets (e.g., .NET 6/7 or .NET Framework), add the package Microsoft.Bcl.TimeProvider and use the same code as those types are backported.

Example 1: replace UtcNow checks

A small service that invalidates tokens after a TTL:

public sealed class TokenCache
{
    private readonly TimeProvider _time;
    private readonly TimeSpan _ttl;
    private readonly ConcurrentDictionary<string, (string Value, DateTimeOffset Created)> _store = new();

    public TokenCache(TimeProvider time, TimeSpan ttl)
    {
        _time = time;
        _ttl = ttl;
    }

    public void Put(string key, string token)
    {
        _store[key] = (token, _time.GetUtcNow());
    }

    public bool TryGet(string key, out string? token)
    {
        if (_store.TryGetValue(key, out var entry))
        {
            var age = _time.GetUtcNow() - entry.Created;
            if (age <= _ttl) { token = entry.Value; return true; }
            _store.TryRemove(key, out _); // expired
        }
        token = null;
        return false;
    }
}

Example 2: periodic testable work

Two options:

A) PeriodicTimer (nice for async loops)

In .NET 9+, PeriodicTimer has a constructor that accepts a TimeProvider:

public sealed class HeartbeatWorker : BackgroundService
{
    private readonly TimeProvider _time;
    public HeartbeatWorker(TimeProvider time) => _time = time;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5), _time);
        while (await timer.WaitForNextTickAsync(stoppingToken))
        {
            await DoHeartbeatAsync(stoppingToken);
        }
    }

    private Task DoHeartbeatAsync(CancellationToken ct)
        => Task.CompletedTask; // your work here
}

B) TimeProvider.CreateTimer(...) (works in .NET 8+)

If you prefer callback-style timers or need to test on .NET 8, create an ITimer:

public sealed class ReminderService : IAsyncDisposable
{
    private readonly ITimer _timer;

    public ReminderService(TimeProvider time, TimeSpan due, TimeSpan period, Action tick)
    {
        _timer = time.CreateTimer(_ => tick(), state: null, due, period);
    }

    public ValueTask DisposeAsync() => _timer.DisposeAsync();
}

CreateTimer ties the schedule to the provider, so advancing a fake clock will invoke the callback.

Deterministic tests with FakeTimeProvider

Use Microsoft.Extensions.TimeProvider.Testing. It ships a FakeTimeProvider that starts at a timestamp you pick and moves only when you tell it to. Advancing time triggers timers and completes delays bound to that provider.

using Microsoft.Extensions.Time.Testing;
using Xunit;

public class TokenCacheTests
{
    [Fact]
    public void Expiry_Is_Driven_By_Fake_Clock()
    {
        var start = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero);
        var clock = new FakeTimeProvider(start);
        var cache = new TokenCache(clock, TimeSpan.FromMinutes(10));

        cache.Put("k", "v1");
        Assert.True(cache.TryGet("k", out var v) && v == "v1");

        // jump 10 minutes + 1 second; no Thread.Sleep
        clock.Advance(TimeSpan.FromMinutes(10) + TimeSpan.FromSeconds(1));

        Assert.False(cache.TryGet("k", out _));
    }
}

Test delay/timeout logic:

[Fact]
public async Task TryPingAsync_Times_Out_Without_Wait()
{
    var clock = new FakeTimeProvider(DateTimeOffset.UnixEpoch);

    var neverCompletes = new Func<CancellationToken, Task>(_ => Task.Delay(Timeout.InfiniteTimeSpan));
    var ok = await TryPingAsync(neverCompletes, TimeSpan.FromSeconds(30), clock);

    // still false until time advances
    Assert.False(ok);

    clock.Advance(TimeSpan.FromSeconds(30));
    // the WaitAsync(timeout, timeProvider) will now complete due to the fake clock
    // Re-run with a task that now "completes" if you want to assert success path
}

DI in tests

Swap the clock in your test host:

var fake = new FakeTimeProvider(DateTimeOffset.Parse("2025-01-01T00:00:00Z"));

var app = new WebApplicationFactory<Program>()
    .WithWebHostBuilder(b => b.ConfigureServices(s =>
    {
        // replace System with fake
        s.AddSingleton<TimeProvider>(fake);
    }));

// You have the ability to jump forward in time 🙂
fake.Advance(TimeSpan.FromMinutes(2));

Conclusion

Treat time as a dependency, not a global. Swap DateTime/Stopwatch/Timer calls for TimeProvider, pass it to delays and waits, and drive tests with FakeTimeProvider. You’ll cut flakiness, drop Thread.Sleep/await Task.Delay(), and keep production behavior intact while tests run fast and repeatable.

Affiliate promo

If you love learning new stuff and want to support me, consider buying a course from Dometrain using this link: Browse courses – Dometrain. Thank you!

Leave a comment

Trending