Back to all posts
·6 min read

Testing time-dependent code in .NET: taking control of the clock with TimeProvider

Time-dependent code is one of the most common sources of flakiness in a test suite. Has a token expired, has a discount campaign ended, when should the next retry happen, which day does a report belong to: all of these depend on knowing "now". When code reads DateTime.UtcNow directly, the test becomes tied to the system clock. The result is familiar: tests that break close to midnight, scenarios that occasionally time out in CI, and slow suites bloated with Thread.Sleep.

In this post I describe how I turn the clock into a dependency using the TimeProvider abstraction in .NET, how I advance time with a fake clock in tests, and a few pitfalls worth watching out for.

The problem: the clock is a hidden dependency

Consider this simple class:

public sealed class CouponService
{
    public bool IsValid(Coupon coupon) =>
        coupon.ExpiresAt > DateTimeOffset.UtcNow;
}

The code looks correct, but it is hard to test. To test an expired coupon you invent a date in the past; to test a valid one you invent a date in the future. The case that actually matters, behavior "exactly at the boundary", is practically untestable, because you cannot control how much time passes between the start of the test and the moment UtcNow is read.

Worse, this dependency does not show up in the signature. Someone looking at the constructor cannot tell that the class needs a clock. If we already inject other external dependencies such as a database or an HTTP client, treating the clock the same way is simply consistent.

Taking TimeProvider as a dependency

Teams used to write their own IClock or ISystemClock interface. In current .NET versions it is a better choice to use the framework's TimeProvider abstract class instead, because not only "now" but also timers, delays and elapsed-time measurement go through the same abstraction.

public sealed class CouponService(TimeProvider time)
{
    public bool IsValid(Coupon coupon) =>
        coupon.ExpiresAt > time.GetUtcNow();
}

In the application you register the real clock with a single line:

builder.Services.AddSingleton(TimeProvider.System);

TimeProvider.System is stateless, so sharing it as a singleton causes no problems. The clock is now an explicit dependency, visible in the class signature.

Replacing the clock with a fake in tests

In tests you can use FakeTimeProvider from the Microsoft.Extensions.TimeProvider.Testing package. It is a clock that does not move until you tell it to:

[Fact]
public void Coupon_expires_exactly_at_expiry_time()
{
    var start = new DateTimeOffset(2026, 3, 1, 12, 0, 0, TimeSpan.Zero);
    var time = new FakeTimeProvider(start);
    var service = new CouponService(time);
    var coupon = new Coupon(ExpiresAt: start.AddMinutes(30));

    Assert.True(service.IsValid(coupon));

    time.Advance(TimeSpan.FromMinutes(30));

    Assert.False(service.IsValid(coupon));
}

This test is deterministic: it gives the same result regardless of the day, the time zone, or how slow the machine is. I recommend always passing the start time explicitly. Whoever reads the test can see when the scenario takes place, and you can deliberately pick special dates such as the end of a month, a leap year, or a daylight saving transition.

Delays and timers

The real payoff shows up in code that waits. Consider a simple retry loop:

public sealed class RetryingSender(IMessageClient client, TimeProvider time)
{
    public async Task SendAsync(Message message, CancellationToken ct)
    {
        for (var attempt = 1; ; attempt++)
        {
            try
            {
                await client.SendAsync(message, ct);
                return;
            }
            catch (TransientException) when (attempt < 3)
            {
                var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt));
                await Task.Delay(delay, time, ct);
            }
        }
    }
}

Thanks to the Task.Delay overload that takes a TimeProvider, the wait is bound to the given clock. The same applies to PeriodicTimer, CancellationTokenSource timeouts and WaitAsync; they also have constructors and overloads that accept a TimeProvider. When you advance the fake clock in a test, pending tasks complete without waiting for real time:

var sendTask = sender.SendAsync(message, CancellationToken.None);

time.Advance(TimeSpan.FromSeconds(2));
time.Advance(TimeSpan.FromSeconds(4));

await sendTask;
Assert.Equal(3, client.CallCount);

There is a subtlety here. A call to Advance only fires timers that have already been created. If you advance the clock before the code has set up its timer, that wait is missed and the test hangs. In asynchronous flows this ordering is not always guaranteed. In practice I take one of two routes: make it observable that the component under test has entered its next wait (for example, by having the fake client signal its call count through a TaskCompletionSource), or advance the clock only once you are sure the wait has started. Sprinkling Task.Delay(100) into the test does not fix the problem; it only hides the flakiness.

Measuring elapsed time

Code that uses Stopwatch for performance measurement or timeout calculations has the same problem. TimeProvider offers GetTimestamp and GetElapsedTime for this:

var started = time.GetTimestamp();
await ProcessAsync(ct);
var elapsed = time.GetElapsedTime(started);

if (elapsed > SlowThreshold)
    logger.LogWarning("Operation completed slowly: {Elapsed}", elapsed);

With a fake clock, this code lets you verify the "a warning is logged if the operation exceeds the threshold" scenario without waiting for seconds. The distinction between wall-clock time and a monotonic timestamp is also preserved: for measuring durations you should use GetTimestamp, not GetUtcNow, because the system clock can jump backward or forward due to synchronization.

Time zones and the notion of "today"

A large share of time-related bugs show up not in "now" but in "today". If the server runs in UTC while the user is in Istanbul, an order created at 01:00 at night belongs to today from the user's perspective and to yesterday from the server's. That is why I keep a few rules strict:

  • Storage and comparison always happen in UTC; DateTimeOffset is preferred.
  • Conversion to a local date happens where the business rule needs it, with an explicit time zone.
  • GetLocalNow or DateTime.Now, which depend on the server's local time zone, are not used in business logic.
var istanbul = TimeZoneInfo.FindSystemTimeZoneById("Europe/Istanbul");
var localToday = DateOnly.FromDateTime(
    TimeZoneInfo.ConvertTime(time.GetUtcNow(), istanbul).DateTime);

With a fake clock, testing this code at moments on either side of the day boundary becomes easy: you provide two start times such as 20:59 and 21:00 UTC and verify that the date changes correctly.

Migrating an existing codebase

In a large project you do not need to replace every DateTime.UtcNow call in one day. This is the order I follow:

  1. Register TimeProvider.System in the DI container.
  2. Move time-sensitive business rules first: expiry, scheduled jobs, retries, billing.
  3. Prevent direct clock reads in new code with an analyzer rule or a code review checklist item.
  4. Take the clock as a parameter in static helpers; where there is no class, making TimeProvider a method argument is enough.

Keeping a static Clock.Now and swapping it in tests may look tempting, but with tests running in parallel, shared state produces new flakiness. An injected dependency does not carry that risk.

Closing

Treating the clock as a dependency looks like a small change, but its effect is large: tests become deterministic, scenarios involving waits run in milliseconds, and boundary cases become genuinely testable. TimeProvider lets you do this with the framework's own tools, without inventing a new abstraction within the team. The next time you investigate a test that "breaks every now and then", make one of the first things you check where the code reads the clock from.