Part V: The Clock Pattern
"DateTime.UtcNow is a hidden dependency."
Every application depends on time. Tokens expire. Subscriptions renew. Audit logs record when things happened. Scheduled tasks fire at intervals. Retry policies wait before their next attempt. Caches invalidate after a TTL. Every one of these behaviors is a function of the current time -- and in most .NET codebases, the current time is obtained by calling DateTime.UtcNow or DateTimeOffset.UtcNow directly.
This is a hidden dependency. It is hidden because it does not appear in any constructor signature. It is not injected. It is not declared. It is a static call buried inside a method body, invisible to the caller, invisible to the test, invisible to the DI container. And it is a dependency because the behavior of the method changes depending on when you call it. A token that is valid at 10:59 is expired at 11:01. A subscription that renews on January 31 behaves differently than one that renews on February 28. A retry policy that waits 30 seconds is untestable if you have to actually wait 30 seconds.
Hidden dependencies are the enemy of testability. If you cannot control time, you cannot test time-dependent behavior deterministically. If you cannot test time-dependent behavior deterministically, you write tests that are either flaky (asserting on DateTime.UtcNow with a tolerance window), slow (using Thread.Sleep to advance time), or absent (skipping the test because it is "too hard").
The Clock pattern solves this by making time an explicit, injectable dependency. You depend on IClock, not on DateTime.UtcNow. In production, you get SystemClock, which delegates to the real system clock. In tests, you get FakeClock, which gives you complete control over time: you set the starting point, you advance by arbitrary durations, and you observe the effects instantly, without waiting.
This chapter covers the complete FrenchExDev.Net.Clock package: the IClock interface, the SystemClock production implementation, the FakeClock test double from the companion .Testing package, real-world examples covering expiration logic, scheduled tasks, timeout handling, and timer callbacks, composition with other FrenchExDev patterns, DI registration, and an honest comparison with .NET 8's TimeProvider.
Why Abstract Time
Before diving into the API, it is worth understanding the three specific problems that motivate a clock abstraction. These are not theoretical concerns. They are bugs that ship to production, tests that waste developer time, and architectural decisions that compound over the life of a project.
Problem 1: Non-Deterministic Tests
Consider a method that creates a token with a one-hour expiry:
public class TokenService
{
public Token CreateToken(string userId)
{
var now = DateTimeOffset.UtcNow;
return new Token(userId, createdAt: now, expiresAt: now.AddHours(1));
}
public bool IsExpired(Token token)
{
return DateTimeOffset.UtcNow >= token.ExpiresAt;
}
}public class TokenService
{
public Token CreateToken(string userId)
{
var now = DateTimeOffset.UtcNow;
return new Token(userId, createdAt: now, expiresAt: now.AddHours(1));
}
public bool IsExpired(Token token)
{
return DateTimeOffset.UtcNow >= token.ExpiresAt;
}
}How do you test IsExpired? The method calls DateTimeOffset.UtcNow internally. You cannot control what it returns. Here are the common approaches, and why each one is broken:
Approach 1: Assert with a tolerance window.
[Fact]
public void Token_is_not_expired_immediately()
{
var service = new TokenService();
var token = service.CreateToken("user-1");
Assert.False(service.IsExpired(token));
}[Fact]
public void Token_is_not_expired_immediately()
{
var service = new TokenService();
var token = service.CreateToken("user-1");
Assert.False(service.IsExpired(token));
}This test passes most of the time. But "most of the time" is not "always." If the test runner is slow, if the machine is under load, if the CI server is overcommitted, the time between CreateToken and IsExpired could cross a meaningful boundary. More importantly, you cannot test the case where the token is almost expired -- say, 59 minutes and 59 seconds in. You would have to wait 59 minutes and 59 seconds.
Approach 2: Sleep. Write await Task.Delay(TimeSpan.FromHours(1)) and wait a real hour. No one will ever run this test. It will be commented out, skipped, or deleted.
Approach 3: Do not test it. This is the most common approach. The developer eyeballs the logic, ships it, and hopes it works. The bug report arrives three weeks later, and no one can reproduce it because it only happens on the last day of the month.
All three approaches fail because the code has a hidden dependency on time. The Clock pattern makes this dependency explicit:
public class TokenService
{
private readonly IClock _clock;
public TokenService(IClock clock) => _clock = clock;
public Token CreateToken(string userId)
{
var now = _clock.UtcNow;
return new Token(userId, createdAt: now, expiresAt: now.AddHours(1));
}
public bool IsExpired(Token token)
{
return _clock.UtcNow >= token.ExpiresAt;
}
}public class TokenService
{
private readonly IClock _clock;
public TokenService(IClock clock) => _clock = clock;
public Token CreateToken(string userId)
{
var now = _clock.UtcNow;
return new Token(userId, createdAt: now, expiresAt: now.AddHours(1));
}
public bool IsExpired(Token token)
{
return _clock.UtcNow >= token.ExpiresAt;
}
}Now the test controls time completely:
[Fact]
public void Token_expires_after_one_hour()
{
var clock = new FakeClock(new DateTimeOffset(2024, 6, 15, 10, 0, 0, TimeSpan.Zero));
var service = new TokenService(clock);
var token = service.CreateToken("user-1");
// 59 minutes: not expired
clock.Advance(TimeSpan.FromMinutes(59));
Assert.False(service.IsExpired(token));
// 61 minutes: expired
clock.Advance(TimeSpan.FromMinutes(2));
Assert.True(service.IsExpired(token));
}[Fact]
public void Token_expires_after_one_hour()
{
var clock = new FakeClock(new DateTimeOffset(2024, 6, 15, 10, 0, 0, TimeSpan.Zero));
var service = new TokenService(clock);
var token = service.CreateToken("user-1");
// 59 minutes: not expired
clock.Advance(TimeSpan.FromMinutes(59));
Assert.False(service.IsExpired(token));
// 61 minutes: expired
clock.Advance(TimeSpan.FromMinutes(2));
Assert.True(service.IsExpired(token));
}This test runs in milliseconds. It is deterministic. It verifies the boundary condition at exactly 59 minutes and exactly 61 minutes. It will never flake.
Problem 2: Time Zone Bugs
The second problem with DateTime.UtcNow is more subtle. Consider a scheduling system that determines whether a task should run "today":
public bool ShouldRunToday(ScheduledTask task)
{
var today = DateTime.Today; // Uses local time zone
return task.ScheduledDate == DateOnly.FromDateTime(today);
}public bool ShouldRunToday(ScheduledTask task)
{
var today = DateTime.Today; // Uses local time zone
return task.ScheduledDate == DateOnly.FromDateTime(today);
}This code works on the developer's machine in Paris (UTC+1). It works on the test server in London (UTC+0). It breaks on the production server in a Docker container configured for UTC when a task is scheduled for "April 3" and the local time is 11 PM UTC on April 2 -- because in Paris, it is already April 3, but in UTC, it is still April 2.
The fix is not to "use UTC everywhere" -- that is necessary but not sufficient. The fix is to make the time zone explicit:
public bool ShouldRunToday(ScheduledTask task, TimeZoneInfo timeZone)
{
var today = DateOnly.FromDateTime(_clock.Now(timeZone).DateTime);
return task.ScheduledDate == today;
}public bool ShouldRunToday(ScheduledTask task, TimeZoneInfo timeZone)
{
var today = DateOnly.FromDateTime(_clock.Now(timeZone).DateTime);
return task.ScheduledDate == today;
}The IClock.Now(TimeZoneInfo) method forces the caller to declare which time zone they mean. There is no implicit "local time." There is no reliance on the server's configured time zone. The time zone is a parameter, not an assumption.
Problem 3: Timer Leaks
The third problem is timers. Real timers in tests are dangerous:
public class CacheRefreshService : IHostedService
{
private Timer? _timer;
public Task StartAsync(CancellationToken ct)
{
_timer = new Timer(RefreshCache, null,
dueTime: TimeSpan.Zero,
period: TimeSpan.FromMinutes(5));
return Task.CompletedTask;
}
private void RefreshCache(object? state)
{
// ... refresh logic
}
public Task StopAsync(CancellationToken ct)
{
_timer?.Dispose();
return Task.CompletedTask;
}
}public class CacheRefreshService : IHostedService
{
private Timer? _timer;
public Task StartAsync(CancellationToken ct)
{
_timer = new Timer(RefreshCache, null,
dueTime: TimeSpan.Zero,
period: TimeSpan.FromMinutes(5));
return Task.CompletedTask;
}
private void RefreshCache(object? state)
{
// ... refresh logic
}
public Task StopAsync(CancellationToken ct)
{
_timer?.Dispose();
return Task.CompletedTask;
}
}If you instantiate this in a test, a real timer starts. It fires every five minutes. If the test does not properly dispose the service, the timer leaks. It keeps firing after the test completes, potentially interfering with other tests. And testing that the cache actually refreshes after five minutes requires -- you guessed it -- waiting five minutes.
With IClock.CreateTimer, the timer delegates to the clock's underlying TimeProvider. In production, this is a real timer. In tests, it is a fake timer controlled by FakeClock.Advance:
public class CacheRefreshService : IHostedService
{
private readonly IClock _clock;
private ITimer? _timer;
public CacheRefreshService(IClock clock) => _clock = clock;
public Task StartAsync(CancellationToken ct)
{
_timer = _clock.CreateTimer(RefreshCache, null,
dueTime: TimeSpan.Zero,
period: TimeSpan.FromMinutes(5));
return Task.CompletedTask;
}
private void RefreshCache(object? state)
{
// ... refresh logic
}
public Task StopAsync(CancellationToken ct)
{
_timer?.Dispose();
return Task.CompletedTask;
}
}public class CacheRefreshService : IHostedService
{
private readonly IClock _clock;
private ITimer? _timer;
public CacheRefreshService(IClock clock) => _clock = clock;
public Task StartAsync(CancellationToken ct)
{
_timer = _clock.CreateTimer(RefreshCache, null,
dueTime: TimeSpan.Zero,
period: TimeSpan.FromMinutes(5));
return Task.CompletedTask;
}
private void RefreshCache(object? state)
{
// ... refresh logic
}
public Task StopAsync(CancellationToken ct)
{
_timer?.Dispose();
return Task.CompletedTask;
}
}Now the test advances time without waiting:
[Fact]
public void Cache_refreshes_every_five_minutes()
{
var clock = new FakeClock();
var refreshCount = 0;
var service = new CacheRefreshService(clock);
// Override the timer callback for testing
// (In practice, you'd test through the public API)
service.StartAsync(CancellationToken.None);
clock.Advance(TimeSpan.FromMinutes(5));
// The fake timer fires synchronously on Advance
// Assert that the cache was refreshed
}[Fact]
public void Cache_refreshes_every_five_minutes()
{
var clock = new FakeClock();
var refreshCount = 0;
var service = new CacheRefreshService(clock);
// Override the timer callback for testing
// (In practice, you'd test through the public API)
service.StartAsync(CancellationToken.None);
clock.Advance(TimeSpan.FromMinutes(5));
// The fake timer fires synchronously on Advance
// Assert that the cache was refreshed
}The timer does not leak. It does not fire unexpectedly. It fires exactly when the test tells it to.
The .NET 8 TimeProvider
Before introducing the IClock interface, we should acknowledge what .NET 8 already provides. The System.TimeProvider abstract class, introduced in .NET 8, is a significant step forward:
public abstract class TimeProvider
{
public static TimeProvider System { get; }
public virtual DateTimeOffset GetUtcNow();
public virtual long GetTimestamp();
public virtual TimeZoneInfo LocalTimeZone { get; }
public virtual ITimer CreateTimer(
TimerCallback callback, object? state,
TimeSpan dueTime, TimeSpan period);
}public abstract class TimeProvider
{
public static TimeProvider System { get; }
public virtual DateTimeOffset GetUtcNow();
public virtual long GetTimestamp();
public virtual TimeZoneInfo LocalTimeZone { get; }
public virtual ITimer CreateTimer(
TimerCallback callback, object? state,
TimeSpan dueTime, TimeSpan period);
}Microsoft also provides FakeTimeProvider in the Microsoft.Extensions.Time.Testing package:
public class FakeTimeProvider : TimeProvider
{
public FakeTimeProvider();
public FakeTimeProvider(DateTimeOffset startDateTime);
public void SetUtcNow(DateTimeOffset value);
public void Advance(TimeSpan delta);
public override DateTimeOffset GetUtcNow();
public override ITimer CreateTimer(
TimerCallback callback, object? state,
TimeSpan dueTime, TimeSpan period);
}public class FakeTimeProvider : TimeProvider
{
public FakeTimeProvider();
public FakeTimeProvider(DateTimeOffset startDateTime);
public void SetUtcNow(DateTimeOffset value);
public void Advance(TimeSpan delta);
public override DateTimeOffset GetUtcNow();
public override ITimer CreateTimer(
TimerCallback callback, object? state,
TimeSpan dueTime, TimeSpan period);
}This is good infrastructure. It solves the core problem of abstracting time. So why does FrenchExDev wrap it instead of using it directly?
What TimeProvider Lacks
TimeProvider provides the core abstraction, but it leaves convenience to the caller:
No
Todayshortcut. You writeDateOnly.FromDateTime(timeProvider.GetUtcNow().UtcDateTime)every time -- risking.DateTimeinstead of.UtcDateTime.IClock.Todayis always correct.No explicit time zone conversion.
TimeProviderhas aLocalTimeZoneproperty but noNow(TimeZoneInfo)method. Every caller writesTimeZoneInfo.ConvertTime(timeProvider.GetUtcNow(), tz).No
Delaymethod. You must remember to pass theTimeProvidertoTask.Delay(delay, timeProvider, ct). If you forget and writeTask.Delay(delay, ct)-- the overload IntelliSense suggests -- your test actually waits.No
CancellationTokenSourcefactory. You must writenew CancellationTokenSource(delay, timeProvider). Forgetting the second parameter silently uses real time.Verbose common case.
timeProvider.GetUtcNow()vsclock.UtcNow. Small difference, but it compounds across hundreds of call sites.
None of these are fatal. The question is whether you want to build these convenience methods in every project, or use a thin wrapper that builds them once.
The IClock Interface
Here is the complete interface:
namespace FrenchExDev.Net.Clock;
public interface IClock
{
DateTimeOffset UtcNow { get; }
DateOnly Today { get; }
TimeProvider TimeProvider { get; }
DateTimeOffset Now(TimeZoneInfo timeZone);
ITimer CreateTimer(
TimerCallback callback,
object? state,
TimeSpan dueTime,
TimeSpan period);
CancellationTokenSource CreateCancellationTokenSource(TimeSpan delay);
Task Delay(TimeSpan delay, CancellationToken ct = default);
}namespace FrenchExDev.Net.Clock;
public interface IClock
{
DateTimeOffset UtcNow { get; }
DateOnly Today { get; }
TimeProvider TimeProvider { get; }
DateTimeOffset Now(TimeZoneInfo timeZone);
ITimer CreateTimer(
TimerCallback callback,
object? state,
TimeSpan dueTime,
TimeSpan period);
CancellationTokenSource CreateCancellationTokenSource(TimeSpan delay);
Task Delay(TimeSpan delay, CancellationToken ct = default);
}Seven members. No more, no less. Each one is a direct, domain-oriented wrapper around TimeProvider functionality, plus the convenience members that TimeProvider does not provide.
Let us walk through each member.
UtcNow
Returns the current UTC time as a DateTimeOffset. This is the most frequently used member. Why DateTimeOffset instead of DateTime? Because DateTimeOffset carries its offset explicitly -- a DateTimeOffset with offset +00:00 is unambiguously UTC, while DateTime with DateTimeKind.Utc can silently become Unspecified as it passes through APIs.
var now = _clock.UtcNow; // replaces DateTimeOffset.UtcNowvar now = _clock.UtcNow; // replaces DateTimeOffset.UtcNowToday
Returns today's UTC date as a DateOnly, saving every caller from writing DateOnly.FromDateTime(UtcNow.UtcDateTime). Ideal for business logic that operates on dates rather than timestamps: subscription periods, deadlines, scheduling.
var today = _clock.Today; // replaces DateOnly.FromDateTime(DateTime.UtcNow)var today = _clock.Today; // replaces DateOnly.FromDateTime(DateTime.UtcNow)TimeProvider
Exposes the underlying System.TimeProvider for interop with APIs that accept TimeProvider directly. This is the escape hatch -- if a third-party library or .NET API takes a TimeProvider parameter, you pass clock.TimeProvider. IClock is not a dead end.
Now(TimeZoneInfo)
Returns the current time in the specified time zone. This is deliberately different from DateTime.Now, which uses the server's local time zone -- a trap that works on your machine and breaks in production because the Docker container runs in UTC.
var parisZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Paris");
var parisTime = _clock.Now(parisZone);var parisZone = TimeZoneInfo.FindSystemTimeZoneById("Europe/Paris");
var parisTime = _clock.Now(parisZone);The time zone is a parameter. There is no hidden default.
CreateTimer
Creates a timer that calls callback after dueTime and then every period. In production, this is a real timer. In tests, the timer fires when you call FakeClock.Advance. The returned ITimer is disposable.
var timer = _clock.CreateTimer(
callback: _ => ProcessQueue(),
state: null,
dueTime: TimeSpan.Zero,
period: TimeSpan.FromSeconds(30));var timer = _clock.CreateTimer(
callback: _ => ProcessQueue(),
state: null,
dueTime: TimeSpan.Zero,
period: TimeSpan.FromSeconds(30));CreateCancellationTokenSource
Creates a CancellationTokenSource that cancels after the specified delay, respecting the underlying TimeProvider. Replaces new CancellationTokenSource(delay), which uses real time regardless of the clock.
using var cts = _clock.CreateCancellationTokenSource(TimeSpan.FromSeconds(30));
var response = await _httpClient.GetAsync(url, cts.Token);using var cts = _clock.CreateCancellationTokenSource(TimeSpan.FromSeconds(30));
var response = await _httpClient.GetAsync(url, cts.Token);Delay
An asynchronous delay that respects the underlying TimeProvider. In tests, this completes instantly when FakeClock.Advance moves past the delay duration. Replaces Task.Delay(delay, ct), which always waits real time.
await _clock.Delay(TimeSpan.FromSeconds(5), cancellationToken);await _clock.Delay(TimeSpan.FromSeconds(5), cancellationToken);Class Diagram
Three types. One interface, two implementations. The interface is the contract. SystemClock is the production implementation. FakeClock is the test implementation. This is the Strategy pattern applied to time.
SystemClock: The Production Implementation
namespace FrenchExDev.Net.Clock;
public sealed class SystemClock : IClock
{
public static readonly SystemClock Instance = new();
public DateTimeOffset UtcNow => TimeProvider.GetUtcNow();
public DateTimeOffset Now(TimeZoneInfo timeZone)
=> TimeZoneInfo.ConvertTime(UtcNow, timeZone);
public DateOnly Today => DateOnly.FromDateTime(UtcNow.UtcDateTime);
public ITimer CreateTimer(
TimerCallback callback,
object? state,
TimeSpan dueTime,
TimeSpan period)
=> TimeProvider.CreateTimer(callback, state, dueTime, period);
public CancellationTokenSource CreateCancellationTokenSource(TimeSpan delay)
=> new(delay, TimeProvider);
public Task Delay(TimeSpan delay, CancellationToken ct = default)
=> Task.Delay(delay, TimeProvider, ct);
public TimeProvider TimeProvider => TimeProvider.System;
}namespace FrenchExDev.Net.Clock;
public sealed class SystemClock : IClock
{
public static readonly SystemClock Instance = new();
public DateTimeOffset UtcNow => TimeProvider.GetUtcNow();
public DateTimeOffset Now(TimeZoneInfo timeZone)
=> TimeZoneInfo.ConvertTime(UtcNow, timeZone);
public DateOnly Today => DateOnly.FromDateTime(UtcNow.UtcDateTime);
public ITimer CreateTimer(
TimerCallback callback,
object? state,
TimeSpan dueTime,
TimeSpan period)
=> TimeProvider.CreateTimer(callback, state, dueTime, period);
public CancellationTokenSource CreateCancellationTokenSource(TimeSpan delay)
=> new(delay, TimeProvider);
public Task Delay(TimeSpan delay, CancellationToken ct = default)
=> Task.Delay(delay, TimeProvider, ct);
public TimeProvider TimeProvider => TimeProvider.System;
}Every method delegates to TimeProvider.System. There is no logic, no branching, no state. SystemClock is a pure adapter: it takes the TimeProvider API and presents it through the IClock interface with the domain-oriented convenience members.
Why Sealed
SystemClock is sealed because there is no reason to subclass it. The production clock does one thing: it returns real time. There is no scenario where you want "real time, but slightly different" -- that is what FakeClock is for. Sealing the class communicates this intent and allows the JIT to devirtualize method calls.
Why Static Readonly Instance
The Instance field is a pre-allocated singleton. This is not the GoF Singleton -- there is no private constructor. You can write new SystemClock(). The Instance field exists for convenience when you are not using DI:
// In a console app or utility without DI
var now = SystemClock.Instance.UtcNow;// In a console app or utility without DI
var now = SystemClock.Instance.UtcNow;The static readonly pattern ensures thread-safe, single initialization with no lazy loading or locking.
Why Not a Static Class
Because IClock is an interface, and static classes cannot implement interfaces. If SystemClock were static, you could not inject it, could not swap it for FakeClock in tests, and would be back to static calls -- exactly the problem we are solving.
Thread Safety
SystemClock is inherently thread-safe because it has no mutable state. The underlying TimeProvider.System is documented as thread-safe by Microsoft.
FakeClock: The Test Double
FakeClock lives in the FrenchExDev.Net.Clock.Testing package -- the companion testing package, separate from the production assembly.
namespace FrenchExDev.Net.Clock.Testing;
public sealed class FakeClock : IClock
{
private readonly FakeTimeProvider _fakeTimeProvider;
public FakeClock(DateTimeOffset startTime)
{
_fakeTimeProvider = new FakeTimeProvider(startTime);
}
public FakeClock()
: this(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero))
{
}
public DateTimeOffset UtcNow => _fakeTimeProvider.GetUtcNow();
public DateTimeOffset Now(TimeZoneInfo timeZone)
=> TimeZoneInfo.ConvertTime(UtcNow, timeZone);
public DateOnly Today => DateOnly.FromDateTime(UtcNow.UtcDateTime);
public ITimer CreateTimer(
TimerCallback callback,
object? state,
TimeSpan dueTime,
TimeSpan period)
=> _fakeTimeProvider.CreateTimer(callback, state, dueTime, period);
public CancellationTokenSource CreateCancellationTokenSource(TimeSpan delay)
=> new(delay, _fakeTimeProvider);
public Task Delay(TimeSpan delay, CancellationToken ct = default)
=> Task.Delay(delay, _fakeTimeProvider, ct);
public TimeProvider TimeProvider => _fakeTimeProvider;
// Test-only members
public void Advance(TimeSpan duration) => _fakeTimeProvider.Advance(duration);
public void SetUtcNow(DateTimeOffset value) => _fakeTimeProvider.SetUtcNow(value);
public FakeTimeProvider FakeTimeProvider => _fakeTimeProvider;
}namespace FrenchExDev.Net.Clock.Testing;
public sealed class FakeClock : IClock
{
private readonly FakeTimeProvider _fakeTimeProvider;
public FakeClock(DateTimeOffset startTime)
{
_fakeTimeProvider = new FakeTimeProvider(startTime);
}
public FakeClock()
: this(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero))
{
}
public DateTimeOffset UtcNow => _fakeTimeProvider.GetUtcNow();
public DateTimeOffset Now(TimeZoneInfo timeZone)
=> TimeZoneInfo.ConvertTime(UtcNow, timeZone);
public DateOnly Today => DateOnly.FromDateTime(UtcNow.UtcDateTime);
public ITimer CreateTimer(
TimerCallback callback,
object? state,
TimeSpan dueTime,
TimeSpan period)
=> _fakeTimeProvider.CreateTimer(callback, state, dueTime, period);
public CancellationTokenSource CreateCancellationTokenSource(TimeSpan delay)
=> new(delay, _fakeTimeProvider);
public Task Delay(TimeSpan delay, CancellationToken ct = default)
=> Task.Delay(delay, _fakeTimeProvider, ct);
public TimeProvider TimeProvider => _fakeTimeProvider;
// Test-only members
public void Advance(TimeSpan duration) => _fakeTimeProvider.Advance(duration);
public void SetUtcNow(DateTimeOffset value) => _fakeTimeProvider.SetUtcNow(value);
public FakeTimeProvider FakeTimeProvider => _fakeTimeProvider;
}FakeClock wraps Microsoft.Extensions.Time.Testing.FakeTimeProvider. It implements the same IClock interface as SystemClock, so it can be injected anywhere IClock is expected. But it adds three members that only exist for testing: Advance, SetUtcNow, and FakeTimeProvider.
The Default Start Time
The parameterless constructor defaults to 2024-01-01T00:00:00Z. This is deliberate: not "now" (which would make tests non-deterministic), not epoch (which triggers date validation failures), but a recent, recognizable, midnight UTC date. When you see 2024-01-01 in test output, you know it came from the fake clock.
var clock = new FakeClock(); // 2024-01-01T00:00:00Z
var clock = new FakeClock(new DateTimeOffset(2026, 3, 15, 14, 30, 0, TimeSpan.Zero));var clock = new FakeClock(); // 2024-01-01T00:00:00Z
var clock = new FakeClock(new DateTimeOffset(2026, 3, 15, 14, 30, 0, TimeSpan.Zero));Advance(TimeSpan)
public void Advance(TimeSpan duration)public void Advance(TimeSpan duration)Moves the clock forward by the specified duration. This is the primary mechanism for controlling time in tests.
var clock = new FakeClock(); // 2024-01-01T00:00:00Z
clock.Advance(TimeSpan.FromHours(3));
// clock.UtcNow is now 2024-01-01T03:00:00Z
clock.Advance(TimeSpan.FromDays(1));
// clock.UtcNow is now 2024-01-02T03:00:00Z
clock.Advance(TimeSpan.FromMilliseconds(500));
// clock.UtcNow is now 2024-01-02T03:00:00.500Zvar clock = new FakeClock(); // 2024-01-01T00:00:00Z
clock.Advance(TimeSpan.FromHours(3));
// clock.UtcNow is now 2024-01-01T03:00:00Z
clock.Advance(TimeSpan.FromDays(1));
// clock.UtcNow is now 2024-01-02T03:00:00Z
clock.Advance(TimeSpan.FromMilliseconds(500));
// clock.UtcNow is now 2024-01-02T03:00:00.500ZAdvance is additive. Each call moves the clock forward from its current position. This is natural for tests that simulate the passage of time: "create the token, advance 30 minutes, check if it is still valid, advance 31 more minutes, check again."
Critically, Advance also triggers any pending timers and completes any pending delays. If you created a timer with a 5-minute period and then call Advance(TimeSpan.FromMinutes(5)), the timer callback fires synchronously during the Advance call. If you called clock.Delay(TimeSpan.FromSeconds(30)) and then Advance(TimeSpan.FromSeconds(30)), the delay task completes. This is what makes FakeClock useful for testing time-dependent behavior without actually waiting.
SetUtcNow(DateTimeOffset)
public void SetUtcNow(DateTimeOffset value)public void SetUtcNow(DateTimeOffset value)Sets the clock to an absolute time. Unlike Advance, which moves forward by a relative amount, SetUtcNow jumps to a specific point in time.
var clock = new FakeClock(); // 2024-01-01T00:00:00Z
clock.SetUtcNow(new DateTimeOffset(2026, 12, 31, 23, 59, 59, TimeSpan.Zero));
// clock.UtcNow is now 2026-12-31T23:59:59Zvar clock = new FakeClock(); // 2024-01-01T00:00:00Z
clock.SetUtcNow(new DateTimeOffset(2026, 12, 31, 23, 59, 59, TimeSpan.Zero));
// clock.UtcNow is now 2026-12-31T23:59:59ZUse SetUtcNow when you need to test behavior at a specific point in time, rather than simulating the passage of time. Common use cases include:
- Testing year-end boundary conditions
- Testing leap year behavior
- Testing daylight saving time transitions
- Resetting the clock between test scenarios
Note that SetUtcNow does not trigger timers or complete delays the way Advance does. If you need to trigger time-dependent callbacks, use Advance.
FakeTimeProvider Property
public FakeTimeProvider FakeTimeProvider => _fakeTimeProvider;public FakeTimeProvider FakeTimeProvider => _fakeTimeProvider;Exposes the underlying FakeTimeProvider for advanced scenarios: passing to third-party libraries that accept TimeProvider, or accessing FakeTimeProvider-specific members. This is the same escape hatch as IClock.TimeProvider, but typed as the concrete fake.
Real-World Examples
Theory is useful, but code is convincing. This section walks through four real-world scenarios where IClock and FakeClock make the difference between a test that works and a test that does not exist.
Expiration Logic
Token expiration is the canonical example. Here is a complete implementation and test suite for a JWT-like token service:
public sealed record AuthToken(
string UserId,
string TokenId,
DateTimeOffset IssuedAt,
DateTimeOffset ExpiresAt)
{
public bool IsExpired(DateTimeOffset now) => now >= ExpiresAt;
public TimeSpan RemainingLifetime(DateTimeOffset now) =>
IsExpired(now) ? TimeSpan.Zero : ExpiresAt - now;
}
[Injectable(Scope = Scope.Scoped)]
public sealed class AuthTokenService : IAuthTokenService
{
private readonly IClock _clock;
private readonly TokenOptions _options;
public AuthTokenService(IClock clock, IOptions<TokenOptions> options)
{
_clock = clock;
_options = options.Value;
}
public AuthToken IssueToken(string userId)
{
var now = _clock.UtcNow;
return new AuthToken(
UserId: userId,
TokenId: Guid.NewGuid().ToString("N"),
IssuedAt: now,
ExpiresAt: now.Add(_options.TokenLifetime));
}
public bool IsValid(AuthToken token)
{
return !token.IsExpired(_clock.UtcNow);
}
public Option<AuthToken> Refresh(AuthToken token)
{
if (token.IsExpired(_clock.UtcNow))
return Option<AuthToken>.None();
var remaining = token.RemainingLifetime(_clock.UtcNow);
if (remaining > _options.RefreshWindow)
return Option<AuthToken>.None(); // Too early to refresh
return IssueToken(token.UserId);
}
}public sealed record AuthToken(
string UserId,
string TokenId,
DateTimeOffset IssuedAt,
DateTimeOffset ExpiresAt)
{
public bool IsExpired(DateTimeOffset now) => now >= ExpiresAt;
public TimeSpan RemainingLifetime(DateTimeOffset now) =>
IsExpired(now) ? TimeSpan.Zero : ExpiresAt - now;
}
[Injectable(Scope = Scope.Scoped)]
public sealed class AuthTokenService : IAuthTokenService
{
private readonly IClock _clock;
private readonly TokenOptions _options;
public AuthTokenService(IClock clock, IOptions<TokenOptions> options)
{
_clock = clock;
_options = options.Value;
}
public AuthToken IssueToken(string userId)
{
var now = _clock.UtcNow;
return new AuthToken(
UserId: userId,
TokenId: Guid.NewGuid().ToString("N"),
IssuedAt: now,
ExpiresAt: now.Add(_options.TokenLifetime));
}
public bool IsValid(AuthToken token)
{
return !token.IsExpired(_clock.UtcNow);
}
public Option<AuthToken> Refresh(AuthToken token)
{
if (token.IsExpired(_clock.UtcNow))
return Option<AuthToken>.None();
var remaining = token.RemainingLifetime(_clock.UtcNow);
if (remaining > _options.RefreshWindow)
return Option<AuthToken>.None(); // Too early to refresh
return IssueToken(token.UserId);
}
}The test suite covers every edge case without waiting a single second:
public class AuthTokenServiceTests
{
private readonly FakeClock _clock;
private readonly AuthTokenService _service;
public AuthTokenServiceTests()
{
_clock = new FakeClock(new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero));
var options = Options.Create(new TokenOptions
{
TokenLifetime = TimeSpan.FromHours(1),
RefreshWindow = TimeSpan.FromMinutes(10)
});
_service = new AuthTokenService(_clock, options);
}
[Fact]
public void Token_is_valid_just_before_expiry()
{
var token = _service.IssueToken("user-1");
_clock.Advance(TimeSpan.FromMinutes(59).Add(TimeSpan.FromSeconds(59)));
Assert.True(_service.IsValid(token));
}
[Fact]
public void Token_is_expired_at_exact_expiry_time()
{
var token = _service.IssueToken("user-1");
_clock.Advance(TimeSpan.FromHours(1));
Assert.False(_service.IsValid(token));
}
[Fact]
public void Refresh_returns_none_when_too_early()
{
var token = _service.IssueToken("user-1");
_clock.Advance(TimeSpan.FromMinutes(30));
var refreshed = _service.Refresh(token);
Assert.True(refreshed.IsNone);
}
[Fact]
public void Refresh_returns_new_token_within_refresh_window()
{
var token = _service.IssueToken("user-1");
_clock.Advance(TimeSpan.FromMinutes(52));
var refreshed = _service.Refresh(token);
Assert.True(refreshed.IsSome);
}
[Fact]
public void Refresh_issues_token_with_full_lifetime()
{
var token = _service.IssueToken("user-1");
_clock.Advance(TimeSpan.FromMinutes(55));
var refreshed = _service.Refresh(token);
var newToken = refreshed.Match(
onSome: t => t,
onNone: () => throw new Exception("Expected Some"));
Assert.Equal(_clock.UtcNow.AddHours(1), newToken.ExpiresAt);
}
}public class AuthTokenServiceTests
{
private readonly FakeClock _clock;
private readonly AuthTokenService _service;
public AuthTokenServiceTests()
{
_clock = new FakeClock(new DateTimeOffset(2024, 6, 1, 12, 0, 0, TimeSpan.Zero));
var options = Options.Create(new TokenOptions
{
TokenLifetime = TimeSpan.FromHours(1),
RefreshWindow = TimeSpan.FromMinutes(10)
});
_service = new AuthTokenService(_clock, options);
}
[Fact]
public void Token_is_valid_just_before_expiry()
{
var token = _service.IssueToken("user-1");
_clock.Advance(TimeSpan.FromMinutes(59).Add(TimeSpan.FromSeconds(59)));
Assert.True(_service.IsValid(token));
}
[Fact]
public void Token_is_expired_at_exact_expiry_time()
{
var token = _service.IssueToken("user-1");
_clock.Advance(TimeSpan.FromHours(1));
Assert.False(_service.IsValid(token));
}
[Fact]
public void Refresh_returns_none_when_too_early()
{
var token = _service.IssueToken("user-1");
_clock.Advance(TimeSpan.FromMinutes(30));
var refreshed = _service.Refresh(token);
Assert.True(refreshed.IsNone);
}
[Fact]
public void Refresh_returns_new_token_within_refresh_window()
{
var token = _service.IssueToken("user-1");
_clock.Advance(TimeSpan.FromMinutes(52));
var refreshed = _service.Refresh(token);
Assert.True(refreshed.IsSome);
}
[Fact]
public void Refresh_issues_token_with_full_lifetime()
{
var token = _service.IssueToken("user-1");
_clock.Advance(TimeSpan.FromMinutes(55));
var refreshed = _service.Refresh(token);
var newToken = refreshed.Match(
onSome: t => t,
onNone: () => throw new Exception("Expected Some"));
Assert.Equal(_clock.UtcNow.AddHours(1), newToken.ExpiresAt);
}
}Five tests. Every boundary condition covered. Total execution time: less than 10 milliseconds. Try doing this with DateTime.UtcNow.
Scheduled Tasks
Many applications have tasks that run on a schedule: daily reports, weekly cleanup, monthly billing. Testing these tasks is notoriously difficult because the schedule spans days or weeks.
[Injectable(Scope = Scope.Singleton)]
public sealed class DailyReportScheduler : IDailyReportScheduler
{
private readonly IClock _clock;
private readonly IReportGenerator _reportGenerator;
private readonly ILogger<DailyReportScheduler> _logger;
private DateOnly _lastRunDate;
public DailyReportScheduler(
IClock clock,
IReportGenerator reportGenerator,
ILogger<DailyReportScheduler> logger)
{
_clock = clock;
_reportGenerator = reportGenerator;
_logger = logger;
_lastRunDate = DateOnly.MinValue;
}
public async Task<bool> TryRunAsync(CancellationToken ct)
{
var today = _clock.Today;
if (today <= _lastRunDate)
{
_logger.LogDebug(
"Report already generated for {Date}, skipping",
today);
return false;
}
_logger.LogInformation(
"Generating daily report for {Date}",
today);
await _reportGenerator.GenerateAsync(today, ct);
_lastRunDate = today;
return true;
}
}[Injectable(Scope = Scope.Singleton)]
public sealed class DailyReportScheduler : IDailyReportScheduler
{
private readonly IClock _clock;
private readonly IReportGenerator _reportGenerator;
private readonly ILogger<DailyReportScheduler> _logger;
private DateOnly _lastRunDate;
public DailyReportScheduler(
IClock clock,
IReportGenerator reportGenerator,
ILogger<DailyReportScheduler> logger)
{
_clock = clock;
_reportGenerator = reportGenerator;
_logger = logger;
_lastRunDate = DateOnly.MinValue;
}
public async Task<bool> TryRunAsync(CancellationToken ct)
{
var today = _clock.Today;
if (today <= _lastRunDate)
{
_logger.LogDebug(
"Report already generated for {Date}, skipping",
today);
return false;
}
_logger.LogInformation(
"Generating daily report for {Date}",
today);
await _reportGenerator.GenerateAsync(today, ct);
_lastRunDate = today;
return true;
}
}The test simulates multiple days without waiting:
public class DailyReportSchedulerTests
{
private readonly FakeClock _clock;
private readonly FakeReportGenerator _reportGenerator;
private readonly DailyReportScheduler _scheduler;
public DailyReportSchedulerTests()
{
_clock = new FakeClock(new DateTimeOffset(2024, 3, 1, 8, 0, 0, TimeSpan.Zero));
_reportGenerator = new FakeReportGenerator();
_scheduler = new DailyReportScheduler(
_clock,
_reportGenerator,
NullLogger<DailyReportScheduler>.Instance);
}
[Fact]
public async Task Does_not_run_twice_on_same_day()
{
await _scheduler.TryRunAsync(CancellationToken.None);
_clock.Advance(TimeSpan.FromHours(4)); // Still March 1
var result = await _scheduler.TryRunAsync(CancellationToken.None);
Assert.False(result);
Assert.Single(_reportGenerator.GeneratedReports);
}
[Fact]
public async Task Runs_again_on_next_day()
{
await _scheduler.TryRunAsync(CancellationToken.None);
_clock.Advance(TimeSpan.FromDays(1)); // Now March 2
var result = await _scheduler.TryRunAsync(CancellationToken.None);
Assert.True(result);
Assert.Equal(2, _reportGenerator.GeneratedReports.Count);
Assert.Equal(new DateOnly(2024, 3, 2), _reportGenerator.GeneratedReports[1]);
}
[Fact]
public async Task Handles_leap_year()
{
_clock.SetUtcNow(new DateTimeOffset(2024, 2, 28, 8, 0, 0, TimeSpan.Zero));
await _scheduler.TryRunAsync(CancellationToken.None);
_clock.Advance(TimeSpan.FromDays(1)); // February 29
var result = await _scheduler.TryRunAsync(CancellationToken.None);
Assert.True(result);
Assert.Equal(new DateOnly(2024, 2, 29),
_reportGenerator.GeneratedReports.Last());
}
[Fact]
public async Task Simulates_full_week()
{
for (int i = 0; i < 7; i++)
{
await _scheduler.TryRunAsync(CancellationToken.None);
_clock.Advance(TimeSpan.FromDays(1));
}
Assert.Equal(7, _reportGenerator.GeneratedReports.Count);
}
}public class DailyReportSchedulerTests
{
private readonly FakeClock _clock;
private readonly FakeReportGenerator _reportGenerator;
private readonly DailyReportScheduler _scheduler;
public DailyReportSchedulerTests()
{
_clock = new FakeClock(new DateTimeOffset(2024, 3, 1, 8, 0, 0, TimeSpan.Zero));
_reportGenerator = new FakeReportGenerator();
_scheduler = new DailyReportScheduler(
_clock,
_reportGenerator,
NullLogger<DailyReportScheduler>.Instance);
}
[Fact]
public async Task Does_not_run_twice_on_same_day()
{
await _scheduler.TryRunAsync(CancellationToken.None);
_clock.Advance(TimeSpan.FromHours(4)); // Still March 1
var result = await _scheduler.TryRunAsync(CancellationToken.None);
Assert.False(result);
Assert.Single(_reportGenerator.GeneratedReports);
}
[Fact]
public async Task Runs_again_on_next_day()
{
await _scheduler.TryRunAsync(CancellationToken.None);
_clock.Advance(TimeSpan.FromDays(1)); // Now March 2
var result = await _scheduler.TryRunAsync(CancellationToken.None);
Assert.True(result);
Assert.Equal(2, _reportGenerator.GeneratedReports.Count);
Assert.Equal(new DateOnly(2024, 3, 2), _reportGenerator.GeneratedReports[1]);
}
[Fact]
public async Task Handles_leap_year()
{
_clock.SetUtcNow(new DateTimeOffset(2024, 2, 28, 8, 0, 0, TimeSpan.Zero));
await _scheduler.TryRunAsync(CancellationToken.None);
_clock.Advance(TimeSpan.FromDays(1)); // February 29
var result = await _scheduler.TryRunAsync(CancellationToken.None);
Assert.True(result);
Assert.Equal(new DateOnly(2024, 2, 29),
_reportGenerator.GeneratedReports.Last());
}
[Fact]
public async Task Simulates_full_week()
{
for (int i = 0; i < 7; i++)
{
await _scheduler.TryRunAsync(CancellationToken.None);
_clock.Advance(TimeSpan.FromDays(1));
}
Assert.Equal(7, _reportGenerator.GeneratedReports.Count);
}
}The test that simulates a full week completes in under a millisecond. The test for leap year behavior -- which would normally require waiting until February 29 -- runs instantly because SetUtcNow jumps to the exact date.
Timeout Handling
Timeouts are another common pattern that depends on time. Here is a service that retries an operation with exponential backoff:
[Injectable(Scope = Scope.Scoped)]
public sealed class RetryService : IRetryService
{
private readonly IClock _clock;
private readonly ILogger<RetryService> _logger;
public RetryService(IClock clock, ILogger<RetryService> logger)
{
_clock = clock;
_logger = logger;
}
public async Task<Result<T>> ExecuteWithRetryAsync<T>(
Func<CancellationToken, Task<T>> operation,
int maxRetries,
TimeSpan baseDelay,
CancellationToken ct)
{
for (int attempt = 0; attempt <= maxRetries; attempt++)
{
try
{
var result = await operation(ct);
return Result<T>.Success(result);
}
catch (Exception ex) when (attempt < maxRetries && !ct.IsCancellationRequested)
{
var delay = baseDelay * Math.Pow(2, attempt);
_logger.LogWarning(ex,
"Attempt {Attempt} failed, retrying in {Delay}",
attempt + 1, delay);
await _clock.Delay(delay, ct);
}
catch (Exception ex)
{
return Result<T>.Failure($"All {maxRetries + 1} attempts failed: {ex.Message}");
}
}
return Result<T>.Failure("Unreachable");
}
}[Injectable(Scope = Scope.Scoped)]
public sealed class RetryService : IRetryService
{
private readonly IClock _clock;
private readonly ILogger<RetryService> _logger;
public RetryService(IClock clock, ILogger<RetryService> logger)
{
_clock = clock;
_logger = logger;
}
public async Task<Result<T>> ExecuteWithRetryAsync<T>(
Func<CancellationToken, Task<T>> operation,
int maxRetries,
TimeSpan baseDelay,
CancellationToken ct)
{
for (int attempt = 0; attempt <= maxRetries; attempt++)
{
try
{
var result = await operation(ct);
return Result<T>.Success(result);
}
catch (Exception ex) when (attempt < maxRetries && !ct.IsCancellationRequested)
{
var delay = baseDelay * Math.Pow(2, attempt);
_logger.LogWarning(ex,
"Attempt {Attempt} failed, retrying in {Delay}",
attempt + 1, delay);
await _clock.Delay(delay, ct);
}
catch (Exception ex)
{
return Result<T>.Failure($"All {maxRetries + 1} attempts failed: {ex.Message}");
}
}
return Result<T>.Failure("Unreachable");
}
}The test verifies retry behavior without actually waiting:
public class RetryServiceTests
{
private readonly FakeClock _clock = new();
private readonly RetryService _service;
public RetryServiceTests()
=> _service = new RetryService(_clock, NullLogger<RetryService>.Instance);
[Fact]
public async Task Returns_failure_after_all_retries_exhausted()
{
var callCount = 0;
var result = await _service.ExecuteWithRetryAsync<int>(
_ =>
{
callCount++;
throw new InvalidOperationException("Permanent failure");
},
maxRetries: 2,
baseDelay: TimeSpan.FromSeconds(1),
CancellationToken.None);
Assert.True(result.IsFailure);
Assert.Equal(3, callCount); // Initial + 2 retries
}
}public class RetryServiceTests
{
private readonly FakeClock _clock = new();
private readonly RetryService _service;
public RetryServiceTests()
=> _service = new RetryService(_clock, NullLogger<RetryService>.Instance);
[Fact]
public async Task Returns_failure_after_all_retries_exhausted()
{
var callCount = 0;
var result = await _service.ExecuteWithRetryAsync<int>(
_ =>
{
callCount++;
throw new InvalidOperationException("Permanent failure");
},
maxRetries: 2,
baseDelay: TimeSpan.FromSeconds(1),
CancellationToken.None);
Assert.True(result.IsFailure);
Assert.Equal(3, callCount); // Initial + 2 retries
}
}Without IClock.Delay, this test would wait 1 + 2 + 4 = 7 real seconds for the exponential backoff. With FakeClock, the delays resolve instantly.
Timer Callbacks
Background services often use periodic timers to poll for work, refresh caches, or send heartbeats. Testing these services requires triggering the timer callback on demand.
[Injectable(Scope = Scope.Singleton)]
public sealed class HeartbeatService : IHostedService, IDisposable
{
private readonly IClock _clock;
private readonly IHealthReporter _reporter;
private ITimer? _timer;
private int _heartbeatCount;
public HeartbeatService(IClock clock, IHealthReporter reporter)
{
_clock = clock;
_reporter = reporter;
}
public int HeartbeatCount => _heartbeatCount;
public Task StartAsync(CancellationToken ct)
{
_timer = _clock.CreateTimer(
callback: SendHeartbeat,
state: null,
dueTime: TimeSpan.Zero,
period: TimeSpan.FromSeconds(30));
return Task.CompletedTask;
}
private void SendHeartbeat(object? state)
{
Interlocked.Increment(ref _heartbeatCount);
_reporter.ReportHealthy(_clock.UtcNow);
}
public Task StopAsync(CancellationToken ct)
{
_timer?.Dispose();
return Task.CompletedTask;
}
public void Dispose() => _timer?.Dispose();
}[Injectable(Scope = Scope.Singleton)]
public sealed class HeartbeatService : IHostedService, IDisposable
{
private readonly IClock _clock;
private readonly IHealthReporter _reporter;
private ITimer? _timer;
private int _heartbeatCount;
public HeartbeatService(IClock clock, IHealthReporter reporter)
{
_clock = clock;
_reporter = reporter;
}
public int HeartbeatCount => _heartbeatCount;
public Task StartAsync(CancellationToken ct)
{
_timer = _clock.CreateTimer(
callback: SendHeartbeat,
state: null,
dueTime: TimeSpan.Zero,
period: TimeSpan.FromSeconds(30));
return Task.CompletedTask;
}
private void SendHeartbeat(object? state)
{
Interlocked.Increment(ref _heartbeatCount);
_reporter.ReportHealthy(_clock.UtcNow);
}
public Task StopAsync(CancellationToken ct)
{
_timer?.Dispose();
return Task.CompletedTask;
}
public void Dispose() => _timer?.Dispose();
}The test triggers heartbeats by advancing the fake clock:
public class HeartbeatServiceTests
{
private readonly FakeClock _clock;
private readonly FakeHealthReporter _reporter;
private readonly HeartbeatService _service;
public HeartbeatServiceTests()
{
_clock = new FakeClock(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
_reporter = new FakeHealthReporter();
_service = new HeartbeatService(_clock, _reporter);
}
[Fact]
public async Task Sends_heartbeat_every_thirty_seconds()
{
await _service.StartAsync(CancellationToken.None);
Assert.Equal(1, _service.HeartbeatCount); // Immediate
_clock.Advance(TimeSpan.FromSeconds(30));
Assert.Equal(2, _service.HeartbeatCount);
_clock.Advance(TimeSpan.FromSeconds(30));
Assert.Equal(3, _service.HeartbeatCount);
}
[Fact]
public async Task Stops_heartbeats_after_stop()
{
await _service.StartAsync(CancellationToken.None);
await _service.StopAsync(CancellationToken.None);
_clock.Advance(TimeSpan.FromMinutes(5));
Assert.Equal(1, _service.HeartbeatCount); // Only the initial one
}
[Fact]
public async Task Simulates_five_minutes_of_heartbeats()
{
await _service.StartAsync(CancellationToken.None);
_clock.Advance(TimeSpan.FromMinutes(5));
// 1 immediate + 10 periodic = 11
Assert.Equal(11, _service.HeartbeatCount);
}
}public class HeartbeatServiceTests
{
private readonly FakeClock _clock;
private readonly FakeHealthReporter _reporter;
private readonly HeartbeatService _service;
public HeartbeatServiceTests()
{
_clock = new FakeClock(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
_reporter = new FakeHealthReporter();
_service = new HeartbeatService(_clock, _reporter);
}
[Fact]
public async Task Sends_heartbeat_every_thirty_seconds()
{
await _service.StartAsync(CancellationToken.None);
Assert.Equal(1, _service.HeartbeatCount); // Immediate
_clock.Advance(TimeSpan.FromSeconds(30));
Assert.Equal(2, _service.HeartbeatCount);
_clock.Advance(TimeSpan.FromSeconds(30));
Assert.Equal(3, _service.HeartbeatCount);
}
[Fact]
public async Task Stops_heartbeats_after_stop()
{
await _service.StartAsync(CancellationToken.None);
await _service.StopAsync(CancellationToken.None);
_clock.Advance(TimeSpan.FromMinutes(5));
Assert.Equal(1, _service.HeartbeatCount); // Only the initial one
}
[Fact]
public async Task Simulates_five_minutes_of_heartbeats()
{
await _service.StartAsync(CancellationToken.None);
_clock.Advance(TimeSpan.FromMinutes(5));
// 1 immediate + 10 periodic = 11
Assert.Equal(11, _service.HeartbeatCount);
}
}The test that simulates five minutes of heartbeats completes instantly. It verifies that 11 heartbeats fire (one immediate plus ten periodic) and that stopping the service prevents further heartbeats. No real timer, no Thread.Sleep, no flakiness.
Test Timeline
The following diagram illustrates the FakeClock testing workflow for token expiration:
The key insight is that the test drives time forward in discrete steps. Between Advance calls, time is frozen. The system under test reads the clock, and the clock returns the same value every time until the test advances it again. This is what makes the tests deterministic: the passage of time is a test input, not an environmental variable.
Composing with Other Patterns
IClock does not exist in isolation. It composes with the other FrenchExDev patterns because time is a cross-cutting concern that touches everything.
Saga Steps That Depend on Time
The Saga pattern (Part IX) orchestrates multi-step processes with compensation. A saga step might check whether a deadline has passed before compensating:
public async Task CompensateAsync(OrderSagaContext context, CancellationToken ct)
{
// Only compensate if the reservation was made less than 24 hours ago
if (context.ReservationCompletedAt.HasValue &&
_clock.UtcNow - context.ReservationCompletedAt.Value < TimeSpan.FromHours(24))
{
await _inventory.ReleaseAsync(context.ProductId, context.Quantity, ct);
}
}public async Task CompensateAsync(OrderSagaContext context, CancellationToken ct)
{
// Only compensate if the reservation was made less than 24 hours ago
if (context.ReservationCompletedAt.HasValue &&
_clock.UtcNow - context.ReservationCompletedAt.Value < TimeSpan.FromHours(24))
{
await _inventory.ReleaseAsync(context.ProductId, context.Quantity, ct);
}
}In tests, FakeClock verifies the time window: execute the step, advance 25 hours, compensate, assert nothing was released. The 24-hour boundary is tested in milliseconds.
Outbox Processor Polling
The Outbox pattern (Part X) uses a background processor that polls for unsent messages. The polling loop uses _clock.Delay instead of Task.Delay:
protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var messages = await _store.GetPendingAsync(_options.BatchSize, ct);
foreach (var message in messages)
{
await _publisher.PublishAsync(message, ct);
message.MarkAsSent(_clock.UtcNow);
await _store.UpdateAsync(message, ct);
}
await _clock.Delay(_options.PollingInterval, ct);
}
}protected override async Task ExecuteAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
var messages = await _store.GetPendingAsync(_options.BatchSize, ct);
foreach (var message in messages)
{
await _publisher.PublishAsync(message, ct);
message.MarkAsSent(_clock.UtcNow);
await _store.UpdateAsync(message, ct);
}
await _clock.Delay(_options.PollingInterval, ct);
}
}Without IClock.Delay, the Delay in the loop would use real time, and the test would have to wait for the actual polling interval. With FakeClock, the delay resolves instantly when Advance moves past the interval.
Guard with Date Validation
The Guard pattern (Part IV) validates inputs. When validating dates, the clock provides the reference point -- _clock.Today replaces DateTime.Today (which uses the server's local time zone):
public Result<Subscription> CreateSubscription(
string userId, DateOnly startDate, DateOnly endDate)
{
var today = _clock.Today;
var validStart = Guard.ToResult.GreaterThanOrEqual(
startDate, today, nameof(startDate));
var validEnd = Guard.ToResult.GreaterThan(
endDate, startDate, nameof(endDate));
if (validStart.IsFailure) return validStart.Map<Subscription>();
if (validEnd.IsFailure) return validEnd.Map<Subscription>();
return Result<Subscription>.Success(
new Subscription(userId, startDate, endDate));
}public Result<Subscription> CreateSubscription(
string userId, DateOnly startDate, DateOnly endDate)
{
var today = _clock.Today;
var validStart = Guard.ToResult.GreaterThanOrEqual(
startDate, today, nameof(startDate));
var validEnd = Guard.ToResult.GreaterThan(
endDate, startDate, nameof(endDate));
if (validStart.IsFailure) return validStart.Map<Subscription>();
if (validEnd.IsFailure) return validEnd.Map<Subscription>();
return Result<Subscription>.Success(
new Subscription(userId, startDate, endDate));
}In tests, set the clock to a known date and verify that yesterday is rejected, today is accepted -- deterministically, regardless of when the test runs.
Mediator Pipeline with Timing
The Mediator pattern (Part VII) supports pipeline behaviors. A timing behavior measures how long each command takes by reading _clock.UtcNow before and after the inner handler:
public async Task<TResult> HandleAsync(
TRequest request,
RequestHandlerDelegate<TResult> next,
CancellationToken ct)
{
var start = _clock.UtcNow;
var result = await next();
var elapsed = _clock.UtcNow - start;
_logger.LogInformation(
"Request {RequestType} completed in {Elapsed}ms",
typeof(TRequest).Name,
elapsed.TotalMilliseconds);
return result;
}public async Task<TResult> HandleAsync(
TRequest request,
RequestHandlerDelegate<TResult> next,
CancellationToken ct)
{
var start = _clock.UtcNow;
var result = await next();
var elapsed = _clock.UtcNow - start;
_logger.LogInformation(
"Request {RequestType} completed in {Elapsed}ms",
typeof(TRequest).Name,
elapsed.TotalMilliseconds);
return result;
}In tests, the inner handler calls clock.Advance(TimeSpan.FromMilliseconds(150)) to simulate work, and the timing behavior logs exactly 150ms -- deterministic, instant, no real delay.
Using the Injectable Attribute
The recommended way to register SystemClock is via the [Injectable] source generator:
[Injectable(Scope = Scope.Singleton)]
public sealed class SystemClock : IClock
{
// ... implementation as shown above
}[Injectable(Scope = Scope.Singleton)]
public sealed class SystemClock : IClock
{
// ... implementation as shown above
}The source generator sees SystemClock implements IClock, sees the [Injectable] attribute with Scope.Singleton, and emits a registration method that adds SystemClock to the DI container as a singleton implementing IClock.
This is the same pattern used across all nine FrenchExDev packages: attributes declare intent, source generators emit registration code, and the DI container receives concrete registrations at compile time.
Manual Registration
If you are not using the [Injectable] source generator, you can register manually:
// Register the singleton instance
services.AddSingleton<IClock>(SystemClock.Instance);// Register the singleton instance
services.AddSingleton<IClock>(SystemClock.Instance);This is equivalent to what the source generator emits. You get the same pre-allocated Instance, the same singleton lifetime, the same IClock service type.
Test Registration
In integration tests or test fixtures that use a DI container, replace the production clock with a FakeClock:
// In a test setup or WebApplicationFactory override
services.AddSingleton<IClock>(new FakeClock());
// Or with a specific start time
services.AddSingleton<IClock>(
new FakeClock(new DateTimeOffset(2024, 6, 15, 0, 0, 0, TimeSpan.Zero)));// In a test setup or WebApplicationFactory override
services.AddSingleton<IClock>(new FakeClock());
// Or with a specific start time
services.AddSingleton<IClock>(
new FakeClock(new DateTimeOffset(2024, 6, 15, 0, 0, 0, TimeSpan.Zero)));Because FakeClock implements IClock, it drops into the same slot. Every service that depends on IClock receives the fake clock. Every call to UtcNow returns the controlled time. Every call to Delay resolves instantly when you call Advance.
Integration Test Example
In integration tests using WebApplicationFactory, replace the production clock:
public class ApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly FakeClock _clock;
public ApiTests(WebApplicationFactory<Program> factory)
{
_clock = new FakeClock(new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.Zero));
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(IClock));
if (descriptor is not null)
services.Remove(descriptor);
services.AddSingleton<IClock>(_clock);
});
});
}
[Fact]
public async Task Expired_token_returns_401()
{
var client = _factory.CreateClient();
var createResponse = await client.PostAsJsonAsync("/api/tokens",
new { UserId = "user-1" });
var token = await createResponse.Content
.ReadFromJsonAsync<TokenResponse>();
_clock.Advance(TimeSpan.FromHours(2));
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token!.Value);
var response = await client.GetAsync("/api/protected-resource");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}public class ApiTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly WebApplicationFactory<Program> _factory;
private readonly FakeClock _clock;
public ApiTests(WebApplicationFactory<Program> factory)
{
_clock = new FakeClock(new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.Zero));
_factory = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(IClock));
if (descriptor is not null)
services.Remove(descriptor);
services.AddSingleton<IClock>(_clock);
});
});
}
[Fact]
public async Task Expired_token_returns_401()
{
var client = _factory.CreateClient();
var createResponse = await client.PostAsJsonAsync("/api/tokens",
new { UserId = "user-1" });
var token = await createResponse.Content
.ReadFromJsonAsync<TokenResponse>();
_clock.Advance(TimeSpan.FromHours(2));
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token!.Value);
var response = await client.GetAsync("/api/protected-resource");
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}The test creates a token, advances time past its expiry, and verifies that the API returns 401. The full HTTP pipeline -- controller, service, authentication middleware -- uses the fake clock. This would be impossible to test reliably with DateTime.UtcNow.
Note that FakeClock in tests should always be registered as a singleton so all services share the same clock instance. If you register it as scoped, each scope gets its own clock, and advancing one does not affect the others.
Why Not Just Use TimeProvider Directly?
This is a fair question, and it deserves an honest answer. TimeProvider is a .NET BCL type. It is maintained by Microsoft. It ships with the runtime. It has FakeTimeProvider in Microsoft.Extensions.Time.Testing. Why add another layer?
Here is the comparison:
| Aspect | TimeProvider Directly | IClock Wrapper |
|---|---|---|
| Get UTC time | timeProvider.GetUtcNow() |
clock.UtcNow |
| Get today's date | DateOnly.FromDateTime(timeProvider.GetUtcNow().UtcDateTime) |
clock.Today |
| Time zone conversion | TimeZoneInfo.ConvertTime(timeProvider.GetUtcNow(), tz) |
clock.Now(tz) |
| Delay | Task.Delay(delay, timeProvider, ct) |
clock.Delay(delay, ct) |
| Cancellation | new CancellationTokenSource(delay, timeProvider) |
clock.CreateCancellationTokenSource(delay) |
| Timer | timeProvider.CreateTimer(cb, state, due, period) |
clock.CreateTimer(cb, state, due, period) |
| Test advance | fakeTimeProvider.Advance(duration) |
fakeClock.Advance(duration) |
| Test set time | fakeTimeProvider.SetUtcNow(value) |
fakeClock.SetUtcNow(value) |
| Forgettable? | Yes -- easy to call DateTime.UtcNow instead |
Less likely -- IClock is the only time API |
The last row is the most important. When you use TimeProvider directly, the temptation to call DateTime.UtcNow or DateTimeOffset.UtcNow is always there. IntelliSense suggests it. Existing code uses it. Copy-paste from Stack Overflow uses it. The TimeProvider parameter is "one more thing to remember."
IClock creates a stronger architectural boundary. When IClock is the declared time dependency throughout the codebase, and when code reviews reject DateTime.UtcNow calls, the clock abstraction is enforced by convention and peer pressure. The convenience members (Today, Now(tz), Delay) make the abstraction more attractive to use than the static calls, which reduces the temptation to bypass it.
There is also the consistency argument. Every FrenchExDev abstraction follows the same structure: an interface in the main package, a production implementation, and a purpose-built test double in the companion .Testing package. IClock/FakeClock fits this pattern. TimeProvider/FakeTimeProvider does not -- it is an abstract class, not an interface, and its test double lives in a separate Microsoft package with a different naming convention.
If you are building a library that does not depend on FrenchExDev, use TimeProvider directly -- it is the standard .NET abstraction. If you are building an application that already uses FrenchExDev patterns, use IClock. The IClock.TimeProvider property bridges the two worlds when needed.
Pitfall 1: Mixing IClock with DateTime.UtcNow
// BAD: defeats the purpose of IClock
public class OrderService
{
private readonly IClock _clock;
public OrderService(IClock clock) => _clock = clock;
public Order CreateOrder(OrderRequest request)
{
return new Order
{
CreatedAt = _clock.UtcNow, // Uses the clock
ModifiedAt = DateTime.UtcNow // Uses the real time -- BUG
};
}
}// BAD: defeats the purpose of IClock
public class OrderService
{
private readonly IClock _clock;
public OrderService(IClock clock) => _clock = clock;
public Order CreateOrder(OrderRequest request)
{
return new Order
{
CreatedAt = _clock.UtcNow, // Uses the clock
ModifiedAt = DateTime.UtcNow // Uses the real time -- BUG
};
}
}If IClock is in the constructor, every time-related call in the class should go through _clock. If you see DateTime.UtcNow, DateTimeOffset.UtcNow, or DateTime.Now in a class that has an IClock dependency, it is a bug. A Roslyn analyzer can catch this automatically.
Pitfall 2: Forgetting to Advance in Tests
// BAD: creates a timer but never advances the clock
[Fact]
public async Task Timer_fires()
{
var clock = new FakeClock();
var fired = false;
var timer = clock.CreateTimer(_ => fired = true, null,
TimeSpan.FromSeconds(5), Timeout.InfiniteTimeSpan);
Assert.True(fired); // FAILS -- the clock has not advanced
}
// GOOD: advance the clock to trigger the timer
[Fact]
public async Task Timer_fires()
{
var clock = new FakeClock();
var fired = false;
var timer = clock.CreateTimer(_ => fired = true, null,
TimeSpan.FromSeconds(5), Timeout.InfiniteTimeSpan);
clock.Advance(TimeSpan.FromSeconds(5));
Assert.True(fired); // Passes
}// BAD: creates a timer but never advances the clock
[Fact]
public async Task Timer_fires()
{
var clock = new FakeClock();
var fired = false;
var timer = clock.CreateTimer(_ => fired = true, null,
TimeSpan.FromSeconds(5), Timeout.InfiniteTimeSpan);
Assert.True(fired); // FAILS -- the clock has not advanced
}
// GOOD: advance the clock to trigger the timer
[Fact]
public async Task Timer_fires()
{
var clock = new FakeClock();
var fired = false;
var timer = clock.CreateTimer(_ => fired = true, null,
TimeSpan.FromSeconds(5), Timeout.InfiniteTimeSpan);
clock.Advance(TimeSpan.FromSeconds(5));
Assert.True(fired); // Passes
}Fake timers do not fire automatically. They fire when Advance moves the clock past the timer's due time. If you create a timer with a 5-second due time and never call Advance, the callback never fires. This is by design -- it gives you complete control -- but it can surprise developers who expect the timer to fire on its own.
Pitfall 3: Using SetUtcNow When You Mean Advance
// BAD: SetUtcNow does not trigger timers
var clock = new FakeClock(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
var timer = clock.CreateTimer(_ => { /* callback */ }, null,
TimeSpan.FromMinutes(5), Timeout.InfiniteTimeSpan);
clock.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 10, 0, TimeSpan.Zero));
// Timer callback may not fire -- SetUtcNow behavior differs from Advance
// GOOD: use Advance to move time forward and trigger timers
clock.Advance(TimeSpan.FromMinutes(10));
// Timer callback fires// BAD: SetUtcNow does not trigger timers
var clock = new FakeClock(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
var timer = clock.CreateTimer(_ => { /* callback */ }, null,
TimeSpan.FromMinutes(5), Timeout.InfiniteTimeSpan);
clock.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 10, 0, TimeSpan.Zero));
// Timer callback may not fire -- SetUtcNow behavior differs from Advance
// GOOD: use Advance to move time forward and trigger timers
clock.Advance(TimeSpan.FromMinutes(10));
// Timer callback firesAdvance and SetUtcNow have different semantics. Advance simulates the passage of time: it moves the clock forward, triggering timers and completing delays along the way. SetUtcNow jumps to a specific point in time. Use Advance when you want time-dependent callbacks to fire. Use SetUtcNow when you want to establish a specific point in time without triggering callbacks.
Pitfall 4: Using a Scoped FakeClock in Tests
// BAD: each scope gets its own FakeClock
services.AddScoped<IClock>(_ => new FakeClock());
// GOOD: share one FakeClock across all services
var clock = new FakeClock();
services.AddSingleton<IClock>(clock);// BAD: each scope gets its own FakeClock
services.AddScoped<IClock>(_ => new FakeClock());
// GOOD: share one FakeClock across all services
var clock = new FakeClock();
services.AddSingleton<IClock>(clock);If each service gets its own FakeClock, advancing one does not affect the others, and your test becomes non-deterministic.
Pitfall 5: Testing Time Zones Without IClock.Now
// BAD: uses DateTime.Now which depends on server time zone
var localTime = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, userTimeZone);
// GOOD: uses IClock.Now which is testable
var localTime = _clock.Now(userTimeZone);// BAD: uses DateTime.Now which depends on server time zone
var localTime = TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, userTimeZone);
// GOOD: uses IClock.Now which is testable
var localTime = _clock.Now(userTimeZone);With FakeClock, you can set the clock to 23:00 UTC on January 1 and verify that Now(parisTimeZone) returns January 2 -- a bug that only manifests when the UTC date and the local date differ, and a common source of production incidents.
Summary
The Clock pattern replaces hidden time dependencies with an explicit, injectable abstraction. Here is how the three approaches compare:
| Dimension | DateTime.UtcNow | TimeProvider | IClock |
|---|---|---|---|
| Testability | Impossible without hacks | Good with FakeTimeProvider | Good with FakeClock |
| Determinism | Non-deterministic | Deterministic with FakeTimeProvider | Deterministic with FakeClock |
| API convenience | DateTime.UtcNow (concise) |
timeProvider.GetUtcNow() (verbose) |
clock.UtcNow (concise) |
| Today shortcut | DateTime.Today (uses local tz!) |
Manual conversion | clock.Today (UTC) |
| Time zone safety | DateTime.Now (implicit local) |
timeProvider.LocalTimeZone |
clock.Now(tz) (explicit) |
| Delay | Task.Delay(delay) (real time) |
Task.Delay(delay, tp, ct) (forgettable) |
clock.Delay(delay, ct) (unforgettable) |
| Timer | new Timer(...) (real timer) |
tp.CreateTimer(...) (respects fake) |
clock.CreateTimer(...) (respects fake) |
| Cancellation | new CTS(delay) (real time) |
new CTS(delay, tp) (forgettable) |
clock.CreateCancellationTokenSource(delay) |
| DI pattern | None (static call) | Abstract class injection | Interface injection |
| FrenchExDev consistency | N/A | Different pattern | Same as all nine patterns |
The key takeaway is not that IClock does something TimeProvider cannot do. It does the same thing, wrapped in a thinner, more domain-oriented API that is harder to misuse and consistent with the rest of the FrenchExDev framework. The convenience members (Today, Now(tz), Delay, CreateCancellationTokenSource) eliminate the boilerplate that TimeProvider requires, and the interface-based design fits naturally into the dependency injection patterns that every FrenchExDev package shares.
Three types. One interface. One production implementation. One test double. Zero hidden dependencies.
Next in the series: Part VI: The Mapper Pattern, where we use source generators to create zero-reflection, compile-time-safe object mapping with IMapper<TSource, TTarget>, five attributes, and generated mapping methods that are faster than runtime reflection and safer than hand-written code.