Testing Time-Dependent Code — Clock Injection and Deterministic Tests
Make time-dependent .NET code testable: inject IClock instead of using DateTime.UtcNow, freeze time in tests, test expiry logic, scheduled jobs, and audit timestamps with full control over the clock.
The DateTime.UtcNow Problem
DateTime.UtcNow is a static property that returns the current system time.
Time-dependent code using DateTime.UtcNow:
→ Test results change depending on when you run the test
→ Cannot reliably test boundary conditions ("exactly 24 hours ago")
→ Tests that pass at 10:00 AM may fail at 11:59 PM
→ Cannot simulate future dates ("what if the INR check was 3 days ago?")
The fix: inject the clock as a dependency.
→ Tests provide a frozen clock at a known time
→ Production uses the real system clock
→ Time-dependent logic becomes deterministic and testableThe IClock Abstraction
// Interface in SharedKernel or Application layer
public interface IClock
{
DateTime UtcNow { get; }
}
// Production implementation
public sealed class SystemClock : IClock
{
public DateTime UtcNow => DateTime.UtcNow;
}
// Test implementation — frozen at a specific moment
public sealed class FrozenClock : IClock
{
public DateTime UtcNow { get; }
public FrozenClock(DateTime frozenAt) => UtcNow = frozenAt;
// Convenience: freeze at a specific date
public static FrozenClock At(int year, int month, int day, int hour = 0, int minute = 0) =>
new(new DateTime(year, month, day, hour, minute, 0, DateTimeKind.Utc));
}
// DI registration:
builder.Services.AddSingleton<IClock, SystemClock>();Testing INR Check Expiry
// Domain rule: INR check must be within 24 hours for Warfarin approval
public sealed class Prescription
{
private readonly IClock _clock; // injected into the aggregate (or passed to method)
public Result Approve(decimal inrValue, DateTime checkedAt)
{
if (checkedAt < _clock.UtcNow.AddHours(-24))
return Result.Failure(Error.Validation("InrCheck",
"INR check must be within the last 24 hours."));
Status = PrescriptionStatus.Approved;
InrValue = inrValue;
return Result.Success();
}
}
// Tests — all deterministic regardless of when they run
public sealed class PrescriptionInrExpiryTests
{
// Anchor point: March 15, 2026, 10:00 AM UTC
private static readonly DateTime Anchor =
new(2026, 3, 15, 10, 0, 0, DateTimeKind.Utc);
private Prescription BuildPrescription(IClock clock) =>
Prescription.CreateDraft(
PatientId.Of(Guid.NewGuid()),
MedicationName.Of("Warfarin"),
DosageValue.Of(5m, "mg"),
clock);
[Fact]
public void Approve_InrCheckedOneHourAgo_Succeeds()
{
var clock = new FrozenClock(Anchor);
var prescription = BuildPrescription(clock);
var checkedAt = Anchor.AddHours(-1); // 1 hour ago
var result = prescription.Approve(2.5m, checkedAt);
result.IsSuccess.Should().BeTrue();
}
[Fact]
public void Approve_InrCheckedExactly24HoursAgo_Succeeds()
{
var clock = new FrozenClock(Anchor);
var prescription = BuildPrescription(clock);
var checkedAt = Anchor.AddHours(-24); // exactly on the boundary
var result = prescription.Approve(2.5m, checkedAt);
result.IsSuccess.Should().BeTrue(); // boundary is inclusive
}
[Fact]
public void Approve_InrCheckedMoreThan24HoursAgo_Fails()
{
var clock = new FrozenClock(Anchor);
var prescription = BuildPrescription(clock);
var checkedAt = Anchor.AddHours(-24).AddSeconds(-1); // just past the boundary
var result = prescription.Approve(2.5m, checkedAt);
result.IsFailure.Should().BeTrue();
result.Error.Code.Should().Be("Validation.InrCheck");
}
}
// These tests produce identical results whether run at 10:00 AM or 11:59 PMTesting Scheduled/Expiry Jobs
// Background job: expire prescriptions that have been in "PendingApproval" for more than 48 hours
public sealed class ExpirePrescriptionsJob
{
private readonly IPrescriptionRepository _repository;
private readonly IClock _clock;
public async Task ExecuteAsync(CancellationToken ct)
{
var cutoff = _clock.UtcNow.AddHours(-48);
var expiring = await _repository.GetPendingOlderThanAsync(cutoff, ct);
foreach (var prescription in expiring)
{
prescription.Expire();
await _repository.SaveAsync(prescription, ct);
}
}
}
// Test: control time to put prescriptions past the 48h cutoff
[Fact]
public async Task ExecuteAsync_ExpiresPrescriptionsOlderThan48Hours()
{
var anchor = FrozenClock.At(2026, 3, 15, 10, 0);
var clock = anchor;
// Prescription created 49 hours before "now"
var oldPrescription = Prescription.CreateDraftAt(anchor.UtcNow.AddHours(-49));
var recentPrescription = Prescription.CreateDraftAt(anchor.UtcNow.AddHours(-1));
var repository = new InMemoryPrescriptionRepository(new[]
{
oldPrescription, recentPrescription
});
var job = new ExpirePrescriptionsJob(repository, clock);
await job.ExecuteAsync(CancellationToken.None);
oldPrescription.Status.Should().Be(PrescriptionStatus.Expired);
recentPrescription.Status.Should().Be(PrescriptionStatus.PendingApproval); // unchanged
}Testing Audit Timestamps
// Audit requirement: created_at and updated_at must record the exact time of the operation
public sealed class AuditInterceptor : SaveChangesInterceptor
{
private readonly IClock _clock;
public AuditInterceptor(IClock clock) => _clock = clock;
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData, InterceptionResult<int> result)
{
var now = _clock.UtcNow;
foreach (var entry in eventData.Context!.ChangeTracker.Entries<IAuditable>())
{
if (entry.State == EntityState.Added)
entry.Entity.CreatedAt = now;
if (entry.State is EntityState.Added or EntityState.Modified)
entry.Entity.UpdatedAt = now;
}
return result;
}
}
// Test: verify correct timestamps are recorded
[Fact]
public async Task SaveChanges_SetsCreatedAtToFrozenClockTime()
{
var frozenTime = FrozenClock.At(2026, 3, 15, 9, 30);
var clock = frozenTime;
var options = new DbContextOptionsBuilder<PrescriptionsDbContext>()
.UseInMemoryDatabase("audit-test")
.AddInterceptors(new AuditInterceptor(clock))
.Options;
await using var db = new PrescriptionsDbContext(options);
var prescription = BuildTestPrescription();
db.Prescriptions.Add(prescription);
await db.SaveChangesAsync();
prescription.CreatedAt.Should().Be(frozenTime.UtcNow);
prescription.UpdatedAt.Should().Be(frozenTime.UtcNow);
}Advanced: Advancing the Clock
// Controllable clock that can advance time step by step
public sealed class ControllableClock : IClock
{
private DateTime _current;
public ControllableClock(DateTime startTime) => _current = startTime;
public DateTime UtcNow => _current;
public void AdvanceBy(TimeSpan duration) => _current += duration;
public void AdvanceTo(DateTime time) => _current = time;
}
// Test: verify an INR check becomes expired after 24 hours pass
[Fact]
public void InrCheck_BecomesExpired_After24Hours()
{
var clock = new ControllableClock(FrozenClock.At(2026, 3, 15, 10, 0).UtcNow);
var prescription = BuildPrescription(clock);
var inrCheck = new InrCheck(2.5m, clock.UtcNow); // recorded now
prescription.RecordInrCheck(inrCheck);
// Check is valid immediately after recording
prescription.IsInrCheckExpired(clock).Should().BeFalse();
// Advance clock by 24 hours and 1 second
clock.AdvanceBy(TimeSpan.FromHours(24).Add(TimeSpan.FromSeconds(1)));
// Check is now expired
prescription.IsInrCheckExpired(clock).Should().BeTrue();
}Production issue I've seen: A system that expired pending prescriptions after 24 hours had a flaky test that failed every Saturday morning. Investigation: the test calculated
DateTime.UtcNow.AddHours(-25)for the "expired" prescription at 11:45 PM Friday. By the time the CI pipeline processed the test result at 12:01 AM Saturday, the DST clock change had shifted the comparison. The test would fail or pass depending on whether it ran before or after midnight during DST changeover. IntroducingFrozenClockmade every timestamp deterministic — the test has not been flaky once in the 18 months since.
Key Takeaway
Replace
DateTime.UtcNowwith an injectedIClockinterface. In tests, useFrozenClockset to a known point in time — boundary tests become precise and deterministic. UseControllableClockto simulate time advancing (for testing expiry and scheduled behaviour). RegisterSystemClockas a singleton in production DI. Every time-dependent rule (INR expiry, prescription expiry, audit timestamps) should be testable without relying on the real system clock.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.