Skip to content
Katabench
Try free
8 min read The Katabench team

TimeProvider testing in C#: stop waiting for the clock

Make TimeProvider testing in C# deterministic. Check expiry and delays with FakeTimeProvider, distinguish elapsed time, and avoid asynchronous clock races.

The expiration test waits a second and checks that the item is gone. It passes locally, fails on a busy build agent, and passes when rerun. Someone increases the delay to two seconds. The suite is now slower, and nobody has established whether an item expires exactly at the deadline or one instant after it.

Time-dependent behavior has two separate inputs: the business rule and the clock. A test that cannot control the clock cannot place the rule precisely at its boundary. Adding a longer sleep does not create that control. It just pays more wall-clock time for another approximation.

TimeProvider testing in C# gives the clock an explicit seam. Production uses the real clock; tests supply a controllable one and advance directly to the instant they want to examine.

Inject one source of time

TimeProvider is included in .NET 8 and later. It exposes UTC time, local time, elapsed-time timestamps, and timer creation. TimeProvider.System is the production implementation. Earlier supported targets can use Microsoft.Bcl.TimeProvider; Microsoft's TimeProvider overview lists the framework support and capabilities.

Place the rule on both sides of its deadline

One tick before

now < ExpiresAt

Lease is valid

Exactly at

now == ExpiresAt

Lease is expired

After the deadline

now > ExpiresAt

Lease stays expired

Fake time makes equality testable. A real sleep usually skips straight from before to after.

A reliable time test moves the clock to the rule's boundary; it does not wait and hope the scheduler lands nearby.

Inject the provider into the component that makes the decision. A simple lease expires at its deadline, so its valid interval ends just before that instant:

C#
public sealed class Lease(TimeProvider clock)
{
    public DateTimeOffset ExpiresAt { get; private set; }

    public void Renew(TimeSpan lifetime)
    {
        if (lifetime <= TimeSpan.Zero)
            throw new ArgumentOutOfRangeException(nameof(lifetime));

        ExpiresAt = clock.GetUtcNow() + lifetime;
    }

    public bool IsExpired => clock.GetUtcNow() >= ExpiresAt;
}

Register TimeProvider.System at the application boundary, for example with services.AddSingleton<TimeProvider>(TimeProvider.System) in a dependency-injection setup. Keep the testing package in the test project. Avoid a fallback constructor that silently creates a different clock, because it gives callers a way to bypass the dependency you just made explicit.

This lease uses an absolute UTC deadline, suitable for a rule whose meaning is "valid until this instant." It assumes renewal happens before use and that the caller supplies a representable deadline. A production domain object should make those lifecycle and range rules explicit as well.

Test exactly before, at, and after expiration

Install Microsoft.Extensions.TimeProvider.Testing in the test project. Its FakeTimeProvider type lives in the Microsoft.Extensions.Time.Testing namespace. The names differ slightly; copying the package name into a using statement is an easy first mistake.

Text
dotnet add package Microsoft.Extensions.TimeProvider.Testing

The following xUnit example uses a fixed UTC instant. It makes no assumption about the machine's local time zone or the date on which the suite happens to run.

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

public sealed class LeaseTests
{
    [Fact]
    public void Lease_expires_at_its_deadline()
    {
        var start = new DateTimeOffset(2030, 4, 10, 12, 0, 0, TimeSpan.Zero);
        var clock = new FakeTimeProvider(start);
        var lease = new Lease(clock);
        lease.Renew(TimeSpan.FromMinutes(5));

        clock.Advance(TimeSpan.FromMinutes(5) - TimeSpan.FromTicks(1));
        Assert.False(lease.IsExpired);

        clock.Advance(TimeSpan.FromTicks(1));
        Assert.True(lease.IsExpired);

        clock.Advance(TimeSpan.FromMinutes(1));
        Assert.True(lease.IsExpired);
    }
}

The one-tick step is a logical boundary check for this in-memory comparison. It makes no claim about an operating system timer firing at 100-nanosecond precision. The test asks whether >= is the intended rule. Replacing it with > makes the equality assertion fail immediately.

Add a renewal test separately: advance four minutes, renew for five more, and verify that the new deadline is nine minutes after the original start. That catches an implementation that extends the old deadline instead of calculating from the renewal instant. It also makes the product decision visible; another product might deliberately want the extension behavior.

A fake clock must also own the delay

Replacing DateTimeOffset.UtcNow is only half the work if the component sleeps between decisions. This method still waits on the real clock: await Task.Delay(delay, cancellationToken). Advancing your fake provider cannot complete that delay because the delay never received it.

Use the overload that accepts the same provider:

C#
public sealed class DelayedValue(TimeProvider clock)
{
    public async Task<string> ReadAsync(CancellationToken cancellationToken)
    {
        await Task.Delay(TimeSpan.FromMinutes(2), clock, cancellationToken);
        return "ready";
    }
}

The provider-aware overload is documented in the Task.Delay API. It allows the test's provider to control when the delay becomes eligible to complete.

C#
[Fact]
public async Task Value_becomes_available_after_the_delay()
{
    var clock = new FakeTimeProvider();
    var subject = new DelayedValue(clock);

    var pending = subject.ReadAsync(CancellationToken.None);
    Assert.False(pending.IsCompleted);

    clock.Advance(TimeSpan.FromMinutes(2));

    var result = await pending.WaitAsync(TimeSpan.FromSeconds(5));
    Assert.Equal("ready", result);
}

The real five-second WaitAsync timeout is a failure guard for a broken test; it does not drive the behavior being tested. The successful path does not spend two minutes waiting. Microsoft shows the same start-advance-await sequence in its FakeTimeProvider testing guide.

Advance time after the operation has registered its wait

The previous example works because calling ReadAsync executes synchronously until it reaches the incomplete provider-backed delay. The test gets the task only after that delay has been created. There is a clear ordering between registering the wait and advancing the clock.

That ordering disappears if you wrap the call in Task.Run. The worker might not start until after the test has advanced two minutes. It then schedules its delay two minutes beyond the new time, and the test appears to hang. An arbitrary Task.Yield or a short sleep is not a readiness protocol; neither proves the worker reached the point you care about.

For a background loop, expose or observe a meaningful readiness signal at the scheduling boundary. Wait until the first operation has started or the next timer has been registered, advance the provider, then await the resulting observable effect. If the code has several asynchronous phases, repeat that handshake for each phase. Do not assume one giant time advance simulates an entire service's lifetime.

Advancing time and completing continuations are different events. A delay can become complete while the continuation that updates your counter has yet to run. Assert after awaiting the task or a signal tied to the effect, rather than reading shared state immediately after Advance.

Separate elapsed time from calendar time

An expiration deadline is an instant. A performance measurement is a duration. A reminder at 09:00 in a customer's time zone is a calendar rule. These are different questions even when all three involve something called "time."

For elapsed durations within a process, use GetTimestamp and GetElapsedTime, which use the provider's timestamp source. Under TimeProvider.System, that source is Stopwatch, as described in the overview. Do not persist those timestamps as cross-process deadlines or compare them across machines. For an absolute expiration, store a UTC instant and define how clock adjustments affect the business rule.

For calendar schedules, retain the relevant time zone and specify daylight-saving behavior. "One day later" and "twenty-four hours later" can lead to different local times across a clock change. A fake UTC clock does not decide whether an ambiguous local time should run once or twice, or what to do when a local time never occurs. Those are requirements that deserve examples.

Keep fake time manual unless automatic advancement is itself part of the test design. If reading the clock advances it, adding a diagnostic read can change the outcome. A fresh provider per test also prevents one test's clock changes or pending timers from leaking into another test.

Use the seam to test the promise

A fake clock belongs to the same family as the stateful doubles described in mocks, stubs, and fakes. Its value is control over behavior, not an expectation that GetUtcNow must be called exactly twice. Caching one clock read during an operation should not fail a test unless that changes the promised result.

Katabench's expiring-cache exercise in the Test Writing track exposes an injected TimeProvider and asks you to write tests that catch planted defects. The grading guide explains the two sides of the check: your suite must accept the correct subject and reject enough faulty variants. Expiry boundaries are a good place to build that skill because a single comparison can change the contract.

Start by removing one real wait from one test. Give the component a shared clock seam, choose the exact boundary, and await the behavior you actually observe. The reward is not merely a faster suite. It is a test that can explain what "expired" means without consulting today's clock.

Practice what you just read

  • Test writing Medium Free, no account needed

    Test the Expiring Cache

    You're given a correct time-to-live cache that reads an injected TimeProvider. Write a clock you can move by hand and the tests that pin every expiry edge.

    Open in the editor: Test the Expiring Cache

More like this: C# unit testing exercises →

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.