Learnixo

Writing Testable Code in C# · Lesson 5 of 5

Abstracting Time, Random, and External State

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 testable

The IClock Abstraction

C#
// 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

C#
// 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 PM

Testing Scheduled/Expiry Jobs

C#
// 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

C#
// 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

C#
// 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. Introducing FrozenClock made every timestamp deterministic — the test has not been flaky once in the 18 months since.


Key Takeaway

Replace DateTime.UtcNow with an injected IClock interface. In tests, use FrozenClock set to a known point in time — boundary tests become precise and deterministic. Use ControllableClock to simulate time advancing (for testing expiry and scheduled behaviour). Register SystemClock as 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.