Development How-to

Your agent's tests pass until midnight: inject TimeProvider and ban the system clock

Agent-written C# reaches for DateTime.UtcNow by default. A fake clock, three boundary tests and one analyzer rule fix it in about 20 minutes.

A clay clock face held still by a small clamp and lever, as if time were frozen for testing

A typical first draft of a 14-day free trial calls DateTime.UtcNow in three places. The tests written next call it again, compare against a value computed a few microseconds earlier, and pass. Mostly. They fail when the clock ticks between two lines, when the suite runs just before midnight UTC, or on the one day a month when someone checks the boundary by hand and finds the trial ends a day early.

None of this is exotic. It’s the default shape of generated code, because the training data is full of DateTime.UtcNow and the prompt never mentioned testing. The fix is a seam the agent can’t forget: an injected TimeProvider, a fake clock in the tests, and a compiler rule that fails the build when anyone, human or agent, reaches for the system clock again.

The example below is a trial-expiry service in C# on .NET 10 with xUnit. The technique works with any coding agent that can read files and run a shell, because the instruction, the tests and the gate all live in the repo.

The code an agent writes first

Here’s the service as a first draft usually looks:

namespace Trial;

public sealed record TrialAccount(string Id, DateTime StartedUtc, DateTime? ExpiresUtc);

public sealed class TrialService
{
    public TrialAccount Start(string id) =>
        new(id, DateTime.UtcNow, DateTime.UtcNow.AddDays(14));

    public int DaysRemaining(TrialAccount account) =>
        Math.Max(0, (account.ExpiresUtc!.Value - DateTime.UtcNow).Days);

    public bool IsExpired(TrialAccount account) =>
        DateTime.UtcNow >= account.ExpiresUtc;
}

And the test that goes with it:

[Fact]
public void New_trial_has_14_days_left()
{
    var service = new TrialService();
    var account = service.Start("acme");

    Assert.Equal(14, service.DaysRemaining(account));
}

Run it and it fails on the first attempt, not at midnight:

Failed Trial.Tests.TrialServiceTests.New_trial_has_14_days_left [14 ms]
  Error Message:
   Assert.Equal() Failure: Values differ
Expected: 14
Actual:   13
Failed!  - Failed:     1, Passed:     0, Skipped:     0, Total:     1

There are two real bugs here and one bad test. Start reads the clock twice, so StartedUtc and the expiry are a few ticks apart. DaysRemaining uses .Days, which truncates, so 13 days, 23 hours and 59 minutes becomes 13. And the test can’t pin either problem down because it doesn’t control time.

What the agent does next, and why to stop it

Shown that failure, an agent has two easy ways out. It can loosen the assertion (Assert.InRange(days, 13, 14)), which turns a defect into a passing test. Or it can add Thread.Sleep or a tolerance window around a comparison, which makes the suite slower and the flake rarer without removing it.

Both fixes leave the production code reading a clock nobody can control. Say so in the prompt before the agent starts:

Time handling rules for this repo:
- Production code never reads the system clock. Inject System.TimeProvider
  and call GetUtcNow(). Read the clock once per operation and reuse the value.
- Tests use FakeTimeProvider from Microsoft.Extensions.Time.Testing.
  Never assert with a tolerance, Thread.Sleep or Task.Delay against real time.
- For any rule that depends on a date, write tests for one tick before the
  boundary, exactly on it, and just after, including across midnight UTC.
- Do not weaken an assertion to make a test pass. If a test fails, say
  whether the code or the test is wrong, and why.

Put it in whatever file your agent reads at the start of a session (CLAUDE.md, AGENTS.md, a Cursor rule). The prompt alone is a request, though. The build is what enforces it, and that comes later.

Step 1: inject the clock

TimeProvider is an abstract class in System, and its docs point to FakeTimeProvider as the way to test time-dependent code. Add the test package:

dotnet add tests/Trial.Tests package Microsoft.Extensions.TimeProvider.Testing

That restored version 10.11.0 here, which targets .NET 10. Now the service takes the clock through its constructor:

namespace Trial;

public sealed record TrialAccount(string Id, DateTimeOffset StartedUtc, DateTimeOffset ExpiresUtc);

public sealed class TrialService(TimeProvider time)
{
    private static readonly TimeSpan TrialLength = TimeSpan.FromDays(14);

    public TrialAccount Start(string id)
    {
        var now = time.GetUtcNow();
        return new(id, now, now + TrialLength);
    }

    public int DaysRemaining(TrialAccount account)
    {
        var left = account.ExpiresUtc - time.GetUtcNow();
        return left <= TimeSpan.Zero ? 0 : (int)Math.Ceiling(left.TotalDays);
    }

    public bool IsExpired(TrialAccount account) =>
        time.GetUtcNow() >= account.ExpiresUtc;
}

Three things changed besides the constructor. The clock is read once in Start. The nullable DateTime? became a non-nullable DateTimeOffset, which removes the ! the draft needed. And DaysRemaining rounds up, because “1 day left” is what a customer expects to see until the trial actually ends. That last one is a product decision, and the first draft made it silently. Now it’s visible in a diff and covered by a test.

In the app, register the real clock once:

builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<TrialService>();

Step 2: tests that own the clock

FakeTimeProvider starts at a time you choose and only moves when the test says so. Starting one second before midnight UTC makes the worst case the default case:

using Microsoft.Extensions.Time.Testing;
using Trial;

namespace Trial.Tests;

public class TrialServiceTests
{
    private static readonly DateTimeOffset Start = new(2026, 11, 9, 23, 59, 59, TimeSpan.Zero);

    private readonly FakeTimeProvider _clock = new(Start);
    private readonly TrialService _service;

    public TrialServiceTests() => _service = new TrialService(_clock);

    [Fact]
    public void New_trial_has_14_days_left()
    {
        var account = _service.Start("acme");

        Assert.Equal(14, _service.DaysRemaining(account));
    }

    [Fact]
    public void One_second_before_expiry_is_still_active_with_one_day_left()
    {
        var account = _service.Start("acme");
        _clock.Advance(TimeSpan.FromDays(14) - TimeSpan.FromSeconds(1));

        Assert.False(_service.IsExpired(account));
        Assert.Equal(1, _service.DaysRemaining(account));
    }

    [Fact]
    public void Expires_exactly_at_the_boundary()
    {
        var account = _service.Start("acme");
        _clock.Advance(TimeSpan.FromDays(14));

        Assert.True(_service.IsExpired(account));
        Assert.Equal(0, _service.DaysRemaining(account));
    }
}

The three tests are the before, on and after of the boundary rule from the prompt. The suite takes milliseconds because nothing waits. Advance moves the clock instantly, which is also what the .NET docs list as the reason to use the fake: no real waiting, and edge cases such as midnight or month ends become ordinary test data.

Passed!  - Failed:     0, Passed:     3, Skipped:     0, Total:     3

If you’re reviewing agent-written tests, this is the pattern to look for: a fixed start time in the fixture, explicit Advance calls, and assertions on exact values. A test that calls DateTime.UtcNow anywhere in its arrange step isn’t controlling time, whatever else it does.

Step 3: delays and timers

Code that waits is the other half of the problem. A reminder loop with Task.Delay(TimeSpan.FromSeconds(1)) either makes the test slow or tempts the agent to shrink the delay under test. Task.Delay has an overload that takes a TimeProvider, so the loop can run on the fake clock:

namespace Trial;

public sealed class Reminder(TimeProvider time)
{
    public async Task WaitUntilDueAsync(DateTimeOffset due, CancellationToken ct = default)
    {
        while (time.GetUtcNow() < due)
            await Task.Delay(TimeSpan.FromSeconds(1), time, ct);
    }
}
[Fact]
public async Task Completes_when_the_clock_reaches_the_due_time()
{
    var clock = new FakeTimeProvider(new DateTimeOffset(2026, 11, 9, 9, 0, 0, TimeSpan.Zero));
    var reminder = new Reminder(clock);

    var wait = reminder.WaitUntilDueAsync(clock.GetUtcNow().AddMinutes(5));
    Assert.False(wait.IsCompleted);

    clock.Advance(TimeSpan.FromMinutes(4));
    Assert.False(wait.IsCompleted);

    clock.Advance(TimeSpan.FromMinutes(1));
    await wait.WaitAsync(TimeSpan.FromSeconds(5));
    Assert.True(wait.IsCompletedSuccessfully);
}

The five-minute wait finishes in a few milliseconds of real time. The WaitAsync with a five-second timeout is a safety net so a bug shows up as a failure and not a hung test run; it never has to be reached.

Step 4: make the compiler remember

Instructions drift out of context over a long session. A build rule doesn’t. Microsoft.CodeAnalysis.BannedApiAnalyzers fails the build on any symbol listed in a BannedSymbols.txt file. Add the package to the production project:

dotnet add src/Trial package Microsoft.CodeAnalysis.BannedApiAnalyzers

That restored 5.6.0. Create src/Trial/BannedSymbols.txt. Each line is a documentation comment ID, a semicolon and the message people will see:

P:System.DateTime.Now;Inject TimeProvider and call GetUtcNow() or GetLocalNow()
P:System.DateTime.UtcNow;Inject TimeProvider and call GetUtcNow()
P:System.DateTimeOffset.Now;Inject TimeProvider and call GetLocalNow()
P:System.DateTimeOffset.UtcNow;Inject TimeProvider and call GetUtcNow()
M:System.Threading.Tasks.Task.Delay(System.TimeSpan);Use Task.Delay(delay, timeProvider)
M:System.Threading.Tasks.Task.Delay(System.TimeSpan,System.Threading.CancellationToken);Use Task.Delay(delay, timeProvider, token)

Then register the file and promote the rule to an error in Trial.csproj. The analyzer reports banned symbols as RS0030, which is a warning until you say otherwise:

<PropertyGroup>
  <WarningsAsErrors>RS0030</WarningsAsErrors>
</PropertyGroup>

<ItemGroup>
  <AdditionalFiles Include="BannedSymbols.txt" />
</ItemGroup>

Now put the old habit back, the way an agent might in a later session:

public async Task<bool> WaitUntilDueAsync(DateTime dueUtc)
{
    while (DateTime.UtcNow < dueUtc)
        await Task.Delay(TimeSpan.FromSeconds(1));
    return true;
}

The build refuses it, and the message tells the agent what to do instead:

src/Trial/Reminder.cs(7,16): error RS0030: The symbol 'DateTime.UtcNow' is banned in this project: Inject TimeProvider and call GetUtcNow()
src/Trial/Reminder.cs(8,19): error RS0030: The symbol 'Task.Delay(TimeSpan)' is banned in this project: Use Task.Delay(delay, timeProvider)
    0 Warning(s)
    2 Error(s)

Agents read compiler output and act on it, so the description text is part of the prompt. Write it as the instruction you’d give a colleague. If the agent also runs dotnet build before finishing, the rule costs nothing to apply and the correction happens without a human.

What goes wrong

Ban the symbols in the production project only. The composition root, where TimeProvider.System is registered, doesn’t need the ban, and test projects don’t either, since a test that measures real elapsed time with a stopwatch is a legitimate exception. If a project has a few justified uses, give it a narrower BannedSymbols.txt and not a suppression comment scattered through the code.

The analyzer only knows the symbols you list. It won’t catch Stopwatch.StartNew() used for business timing, Environment.TickCount64, or a third-party library that reads the clock internally. Add the ones your code base actually uses. DateTime.Today and DateTimeOffset.Now are the usual misses.

A fake clock also doesn’t fix time zones. GetUtcNow() is the right default for stored values, but “end of the day” rules depend on the customer’s zone, and TimeProvider.LocalTimeZone is what the fake lets you override. If your product has that rule, add boundary tests for a zone with a daylight-saving change, because that is where generated date arithmetic goes wrong next.

And skip all of this for code where the time is incidental: a log timestamp or a cache entry that lives for an hour. Injecting a clock into every class is noise. Do it where a decision depends on the value.

First 20 minutes

Search the repo for the cheap targets first:

grep -rnE "DateTime(Offset)?\.(Now|UtcNow|Today)" --include=*.cs src | head -20

Pick the file with a business rule in it (expiry, retry back-off, rate limit, scheduling), inject TimeProvider, write the before, on and after tests with the prompt above, and then add the three-line BannedSymbols.txt for that one project. The next time an agent edits the file, the build tells it what the house rules are.