Controlling Time in Tests with TimeProvider and FakeTimeProvider
Objective
Code that reads DateTime.UtcNow or waits with Task.Delay(TimeSpan) gives its tests two bad options:
wait for real time to pass, or check "close enough to now". Since .NET 8 the runtime has an abstraction
for time, TimeProvider, and the overloads that accept one: Task.Delay, Task.WaitAsync,
CancellationTokenSource and PeriodicTimer. In tests, FakeTimeProvider from
Microsoft.Extensions.TimeProvider.Testing replaces it with a clock that only moves when the test says
so. The goal is to inject time everywhere it is read, test every limit exactly at its boundary, drive
timers and delays without sleeping, and know what FakeTimeProvider really does when you call
Advance.
Use Cases
- Testing a 14-day trial, a 15-minute token or a coupon at the exact instant it expires.
- Testing a rate limit or a cache whose window is reset by a timer.
- Testing a retry with backoff and a deadline in milliseconds instead of seconds.
- Testing rules that depend on the local time of a user (quiet hours, "today") on any machine.
- Replacing
ISystemClock, which ASP.NET Core 8 made obsolete, withTimeProvider.
Deep Dive
The hidden clock
A static clock is invisible in the signature, and a test cannot replace it:
csharp// Broken: the expiry depends on the moment the test runs.
public sealed class CouponBroken
{
public DateTime ExpiresAt { get; } = DateTime.UtcNow.AddDays(7);
public bool IsValid => DateTime.UtcNow < ExpiresAt;
}The fix takes time as a dependency. TimeProvider is an abstract class in the runtime (in the
Microsoft.Bcl.TimeProvider package for .NET Framework, .NET Standard 2.0 and .NET 5 to 7), and
TimeProvider.System is the real clock:
csharppublic sealed class Coupon(TimeProvider time)
{
public DateTimeOffset ExpiresAt { get; } = time.GetUtcNow().AddDays(7);
public bool IsValid => time.GetUtcNow() < ExpiresAt;
}Its members cover what code usually needs: GetUtcNow, GetLocalNow and LocalTimeZone,
GetTimestamp and GetElapsedTime for measuring, and CreateTimer, which returns an ITimer.
Register it once and let the container inject it. The generic host does not register a
TimeProvider by itself (checked with Host.CreateApplicationBuilder on .NET 10), so add it:
csharpbuilder.Services.AddSingleton(TimeProvider.System);
A test that builds the same services swaps in the fake:
csharpvar clock = new FakeTimeProvider();
services.AddSingleton<TimeProvider>(clock);ASP.NET Core went the same way. ISystemClock, which authentication and Identity used since 1.0, is
obsolete since ASP.NET Core 8: the options classes now have a TimeProvider property, and the old
SystemClock truncated to whole seconds while TimeProvider does not.
FakeTimeProvider: a clock that moves when you say so
plaintext<PackageReference Include="MSTest" Version="4.5.1" /> <PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.11.0" />
These are the members and behaviors this concept relies on, checked with version 10.11.0:
| Member | What it does |
|---|---|
new FakeTimeProvider() |
starts at 2000-01-01 00:00 UTC, local zone UTC |
new FakeTimeProvider(start) |
starts at start |
Advance(delta) |
moves forward and fires every timer that became due; a negative delta throws |
SetUtcNow(value) |
jumps forward and fires due timers; a value in the past throws |
AdjustTime(value) |
sets the time, forward or back, without firing timers (like changing a system clock) |
AutoAdvanceAmount |
each read of the clock returns the time and then advances it by this amount (default zero) |
SetLocalTimeZone(zone) |
the zone used by GetLocalNow |
Two details the documentation does not spell out, both observed here. AutoAdvanceAmount also advances on
GetTimestamp, so with it on, GetElapsedTime measures your reads too. And the fake keeps the offset of
the value you give it: new FakeTimeProvider(new DateTimeOffset(2026, 10, 18, 9, 0, 0, TimeSpan.FromHours(-3))) makes GetUtcNow() return a value with a -03:00 offset, so .DateTime
reads 09:00 instead of 12:00. Comparisons between DateTimeOffset values still work; formatting and
.DateTime do not. Hand it UTC values (ToUniversalTime()).
Start the fake at a fixed instant, not at DateTimeOffset.UtcNow. Expected values become exact, and
code that still reads the real clock somewhere disagrees with the fake at once:
csharp// A fixed instant, far from the real "now".
private readonly FakeTimeProvider _clock = new(new DateTimeOffset(2026, 1, 5, 9, 0, 0, TimeSpan.Zero));Test the limit, not "well past it"
A limit has two sides. Advancing 16 days to check a 14-day trial passes even when the trial lasts 15. Test one tick before and exactly at the limit, and write the 14 days in the test instead of reusing the production constant:
csharpprivate static readonly TimeSpan FourteenDays = TimeSpan.FromDays(14);
[TestMethod]
public void ATrialIsStillActiveOneTickBeforeFourteenDays()
{
var trials = new TrialService(_clock);
var trial = trials.Start(Customer);
_clock.Advance(FourteenDays - TimeSpan.FromTicks(1));
Assert.AreEqual(TrialStatus.Active, trials.StatusOf(trial));
}
[TestMethod]
public void ATrialExpiresExactlyFourteenDaysAfterItStarts()
{
var trials = new TrialService(_clock);
var trial = trials.Start(Customer);
_clock.Advance(FourteenDays);
Assert.AreEqual(TrialStatus.Expired, trials.StatusOf(trial));
}The pair catches < written as <= and a trial of the wrong length; either test alone misses one of
them.
Timers fire inside Advance
FakeTimeProvider.CreateTimer returns a timer that fires when the fake clock reaches its due time. In
every run for this concept the callback ran synchronously, on the thread that called Advance, so the
next line of the test already sees its effect. A login throttle that resets its counter every minute:
csharp_reset = time.CreateTimer(_ => Reset(), state: null, dueTime: window, period: window);csharpusing var throttle = new LoginThrottle(3, TimeSpan.FromMinutes(1), _clock);
for (var i = 0; i < 3; i++)
{
throttle.TryAttempt();
}
_clock.Advance(TimeSpan.FromMinutes(1) - TimeSpan.FromTicks(1));
Assert.IsFalse(throttle.TryAttempt());
_clock.Advance(TimeSpan.FromTicks(1)); // the timer callback runs inside Advance
Assert.IsTrue(throttle.TryAttempt());One Advance that covers several periods fires the timer once per period: advancing 35 seconds on a
10-second timer that had fired once took the count from 1 to 4. SetUtcNow fires due timers too;
AdjustTime does not.
Delays, deadlines and the await-before-advance hang
Task.Delay(delay, timeProvider), new CancellationTokenSource(timeout, timeProvider) and
task.WaitAsync(timeout, timeProvider) all run on the provider's timers. A retry that waits 1, 2 and 4
seconds and gives up after 10 seconds uses two of them:
csharpusing var deadline = new CancellationTokenSource(Deadline, time);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct, deadline.Token);
// ...
await Task.Delay(wait, time, linked.Token);The test starts the work, moves the clock, and awaits at the end. Awaiting first never returns, because
the only code that could move the clock is the line after the await. [Timeout] turns that hang into
a failure ("timed out after 2000ms") instead of a run that never ends:
csharp[TestMethod]
[Timeout(2000)]
public async Task TheAttemptsFollowTheBackoffOfOneTwoAndFourSeconds()
{
var attempts = 0;
var run = new DeliveryRetry(_clock).RunAsync(_ => Task.FromResult(++attempts == 4), testContext.CancellationToken);
Assert.AreEqual(1, attempts);
_clock.Advance(TimeSpan.FromSeconds(1) - TimeSpan.FromTicks(1));
Assert.AreEqual(1, attempts);
_clock.Advance(TimeSpan.FromTicks(1));
Assert.AreEqual(2, attempts);
_clock.Advance(TimeSpan.FromSeconds(2));
Assert.AreEqual(3, attempts);
_clock.Advance(TimeSpan.FromSeconds(4));
Assert.IsTrue(await run);
}The counter checks between the Advance calls work because, in these runs, the continuation after the
fake Task.Delay also ran inside Advance, even with ConfigureAwait(false). MSTest installs no
SynchronizationContext for a test, so nothing posted the continuation elsewhere. That is observed
behavior, not a documented promise: when the code under test awaits real I/O between delays, keep the
final await and assert after it. Thread.Sleep or Task.Delay without the provider in the test does
nothing for a fake clock except make the test slower.
The deadline works the same way. An attempt that never answers is abandoned exactly at 10 seconds:
csharpvar run = new DeliveryRetry(_clock).RunAsync(
async ct =>
{
await Task.Delay(Timeout.InfiniteTimeSpan, ct); // a server that never answers
return true;
},
testContext.CancellationToken);
_clock.Advance(TimeSpan.FromSeconds(10) - TimeSpan.FromTicks(1));
Assert.IsFalse(run.IsCompleted);
_clock.Advance(TimeSpan.FromTicks(1));
Assert.IsFalse(await run);PeriodicTimer for background loops
new PeriodicTimer(period, timeProvider) is the timer for an async loop, the shape of most
BackgroundService workers:
csharppublic async Task RunAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(1), time);
while (await timer.WaitForNextTickAsync(ct))
{
Sent++;
}
}With the loop running, Advance(1 hour) made Sent 1 and a further Advance(3 hours) made it 4: the
loop body ran and waited again inside each firing. When nobody is waiting, ticks do not queue up:
advancing three periods before the first WaitForNextTickAsync completed that call at once, and the
next one waited for a new period. Only one WaitForNextTickAsync may be in flight at a time.
Local time on any machine
A rule in local time must not depend on the zone of the machine running the tests. A custom zone works on every operating system:
csharpprivate static readonly TimeZoneInfo UtcMinus3 =
TimeZoneInfo.CreateCustomTimeZone("Test/UTC-03", TimeSpan.FromHours(-3), "UTC-03", "UTC-03");
[TestMethod]
[DataRow(21, 59, false)]
[DataRow(22, 0, true)]
[DataRow(6, 59, true)]
[DataRow(7, 0, false)]
public void QuietHoursRunFromTenAtNightToSevenInTheMorningLocalTime(int hour, int minute, bool expected)
{
var clock = new FakeTimeProvider();
clock.SetLocalTimeZone(UtcMinus3);
var local = new DateTimeOffset(2026, 1, 5, hour, minute, 0, TimeSpan.FromHours(-3));
clock.SetUtcNow(local.ToUniversalTime());
Assert.AreEqual(expected, new QuietHours(clock).IsQuietNow());
}The production side reads time.GetLocalNow() and uses TimeOnly.IsBetween(22:00, 07:00), which
handles a range that wraps past midnight.
Do the tests protect the rules?
These are the results for the lab's solution (16 tests), one change at a time:
| Change to the production code | Tests that fail |
|---|---|
StatusOf uses <= instead of < |
the "expires exactly at 14 days" test |
| The trial lasts 15 days | the "exactly at 14 days" and "remaining time" tests |
A nested type reads DateTimeOffset.UtcNow instead of the injected clock |
the two "still valid" token tests |
Token expiry uses > instead of >= |
the "expires exactly at 15 minutes" test |
| The throttle timer fires 1 s late | the boundary reset test |
The backoff calls Task.Delay(wait, token) |
the backoff schedule test |
The deadline is new CancellationTokenSource(Deadline) |
the hung attempt test |
QuietHours reads GetUtcNow |
the 21:59 and 06:59 rows |
The weak versions confirm the advice: a throttle test that advances two minutes lets the late timer
through (16 of 16 green), and token tests with the fake started at DateTimeOffset.UtcNow catch the
forgotten clock in one test instead of two.
Trade-offs
- One more constructor parameter everywhere. Every class that reads time takes a
TimeProvider. It is honest (the dependency is visible) and cheap with DI, but it spreads. Code that only needs "now" once can receive aDateTimeOffsetargument instead, which needs no fake at all.csharppublic bool IsExpiredAt(DateTimeOffset now) => now >= ExpiresAt; - Synchronous firing is observed, not promised. Assertions between
Advancecalls rely on callbacks and continuations running insideAdvance. Keep a finalawaitso the test stays correct if that changes or if the code awaits real I/O. - Fake time does not cover real concurrency. Two threads racing on the counter are not exercised by a fake clock. Locks still need their own tests or reasoning.
TimeProvider.Systemeverywhere else. The fake is for tests. Mixing the fake and the real clock in one test (a fake for the service,TimeProvider.SystemorDateTime.UtcNowin the test's expected values) gives results that depend on when the test runs, which Microsoft's guidance also warns against.- Libraries that do not take a
TimeProviderstay real. A dependency that callsDateTime.UtcNowinternally cannot be controlled. Wrap it behind a port, or test it with real time and generous limits. - xUnit and NUnit use the same package.
FakeTimeProviderdoes not depend on a test framework; only[Timeout]andTestContext.CancellationTokenin the lab are MSTest-specific.