Learnixo

Writing Testable Code in C# · Lesson 3 of 5

Avoiding Static Methods and Singletons

Why Static Hurts Testability

Static state is shared across all tests running in the same process.
  → Test A sets a static flag
  → Test B reads the static flag — value depends on whether Test A ran first
  → Test order determines test outcomes
  → Tests cannot run in parallel safely

Static method calls are hidden dependencies:
  → The class under test calls a static method
  → The static method has side effects (DB write, HTTP call, file system)
  → You cannot replace the static method with a test double
  → You cannot test the class in isolation

Static service locator is the worst form:
  → ServiceLocator.Get() in a method
  → No constructor shows this dependency — impossible to see from the outside
  → Cannot be substituted in tests without configuring the full container

Static Method — Before and After

C#
// BEFORE: static helper with hidden I/O dependency
public sealed class PrescriptionApprovalService
{
    public async Task<Result> ApproveAsync(Guid prescriptionId)
    {
        // Static method reads from config file, makes an HTTP call
        var isFeatureEnabled = FeatureFlags.IsEnabled("warfarin-enhanced-check");

        if (isFeatureEnabled)
        {
            // Static logger — cannot verify in tests
            Logger.Log($"Enhanced check enabled for prescription {prescriptionId}");
        }

        // Static database helper — cannot substitute in tests
        var prescription = DatabaseHelper.Query<Prescription>(
            "SELECT * FROM prescriptions WHERE id = @id",
            new { id = prescriptionId });

        // ...
    }
}

// AFTER: all dependencies injectable
public sealed class PrescriptionApprovalService
{
    private readonly IPrescriptionRepository _repository;
    private readonly IFeatureFlags           _featureFlags;
    private readonly ILogger<PrescriptionApprovalService> _logger;

    public PrescriptionApprovalService(
        IPrescriptionRepository repository,
        IFeatureFlags featureFlags,
        ILogger<PrescriptionApprovalService> logger)
    {
        _repository   = repository;
        _featureFlags = featureFlags;
        _logger       = logger;
    }

    public async Task<Result> ApproveAsync(Guid prescriptionId)
    {
        var isFeatureEnabled = await _featureFlags.IsEnabledAsync("warfarin-enhanced-check");

        if (isFeatureEnabled)
            _logger.LogInformation("Enhanced check enabled for {PrescriptionId}", prescriptionId);

        var prescription = await _repository.GetByIdAsync(
            PrescriptionId.Of(prescriptionId), CancellationToken.None);
        // ...
    }
}

Static Mutable State Between Tests

C#
// BAD: static field that accumulates state across tests
public static class AuditLog
{
    private static readonly List<string> _entries = new(); // static mutable list

    public static void Add(string entry) => _entries.Add(entry);
    public static IReadOnlyList<string> GetAll() => _entries.AsReadOnly();
}

// Test A creates an audit entry:
[Fact]
public void Test_A_ApproveCreatesAuditEntry()
{
    AuditLog.Add("Prescription approved");
    AuditLog.GetAll().Should().HaveCount(1); // passes if Test A runs first
}

// Test B expects an empty audit log:
[Fact]
public void Test_B_StartsWithEmptyAuditLog()
{
    AuditLog.GetAll().Should().BeEmpty(); // FAILS if Test A ran first
}

// FIX: audit log as an injected, instance-scoped service
public interface IAuditLog
{
    void Add(string entry);
    IReadOnlyList<string> GetAll();
}

public sealed class InMemoryAuditLog : IAuditLog
{
    private readonly List<string> _entries = new(); // instance-scoped
    public void Add(string entry) => _entries.Add(entry);
    public IReadOnlyList<string> GetAll() => _entries.AsReadOnly();
}

// Each test gets a fresh InMemoryAuditLog() — no shared state

Service Locator Antipattern

C#
// BAD: service locator — a static registry of all services
public sealed class PrescriptionApprovalService
{
    public async Task<Result> ApproveAsync(Guid prescriptionId)
    {
        // Grabs repository from a global static container
        var repository = ServiceLocator.Current.GetInstance<IPrescriptionRepository>();

        // This is a hidden dependency:
        //   → Not visible in the constructor
        //   → Impossible to substitute in tests without configuring ServiceLocator
        //   → Tests must set up ServiceLocator before calling Approve
    }
}

// You can test this, but only by pre-configuring the global locator:
ServiceLocator.SetLocatorProvider(() => myMockContainer);
// This is shared across all parallel tests — flaky by design

// FIX: constructor injection — dependency is explicit and substitutable
public sealed class PrescriptionApprovalService
{
    private readonly IPrescriptionRepository _repository;

    public PrescriptionApprovalService(IPrescriptionRepository repository) =>
        _repository = repository;

    public async Task<Result> ApproveAsync(Guid prescriptionId)
    {
        var prescription = await _repository.GetByIdAsync(...);
        // ...
    }
}

// Test: trivially provide any implementation
var service = new PrescriptionApprovalService(
    Substitute.For<IPrescriptionRepository>());

When Static Is Acceptable

Static IS acceptable for:
  → Pure functions (no side effects, no I/O, same input always produces same output)
  → Constants and configuration values
  → Extension methods
  → Factory methods that don't touch infrastructure

Examples:
  // Pure function — always testable
  public static DosageValue Of(decimal amount, string unit)
  {
      if (amount <= 0) throw new ArgumentOutOfRangeException(nameof(amount));
      return new DosageValue(amount, unit);
  }

  // Extension method — no state
  public static string ToDisplayString(this PrescriptionStatus status) =>
      status switch
      {
          PrescriptionStatus.Draft     => "Awaiting Approval",
          PrescriptionStatus.Approved  => "Approved",
          PrescriptionStatus.Suspended => "Suspended",
          _                            => status.ToString()
      };

  // Constant
  private const int InrValidityHours = 24;

None of these cause test interference because they have no mutable state
and no I/O — tests can call them directly and get predictable results.

Replacing DateTime.UtcNow with IClock

C#
// DateTime.UtcNow is static — tests cannot control the current time

// BAD: testing time-dependent logic
public bool IsInrCheckExpired(DateTime checkedAt) =>
    checkedAt < DateTime.UtcNow.AddHours(-24);

// Test: "how do I set the clock to a specific time in a test?"
// You can't — DateTime.UtcNow is always "now"

// GOOD: inject a clock
public interface IClock
{
    DateTime UtcNow { get; }
}

public sealed class SystemClock : IClock
{
    public DateTime UtcNow => DateTime.UtcNow;
}

public sealed class FrozenClock : IClock
{
    public DateTime UtcNow { get; }
    public FrozenClock(DateTime frozenAt) => UtcNow = frozenAt;
}

// Now testable:
var frozenAt = new DateTime(2026, 3, 15, 10, 0, 0, DateTimeKind.Utc);
var clock    = new FrozenClock(frozenAt);
var service  = new PrescriptionApprovalService(_repository, clock);

// Test: checkedAt is 25 hours before frozen time
var checkedAt = frozenAt.AddHours(-25);
service.IsInrCheckExpired(checkedAt).Should().BeTrue();

// Test: checkedAt is 1 hour before frozen time
var recentCheck = frozenAt.AddHours(-1);
service.IsInrCheckExpired(recentCheck).Should().BeFalse();

Production issue I've seen: A team's nightly job that expired pending prescriptions after 24 hours was "tested" with a static DateTime.UtcNow comparison. In tests, developers manually set the checkedAt value to 25 hours ago relative to when the test ran. One Friday afternoon, a developer ran the tests at 17:05 and everything passed. The CI pipeline ran at 17:06 (1 minute later) — one test had a boundary condition at exactly 24 hours, and with the 1-minute difference in test execution time, it flipped from passing to failing. The test was flaky because "now" wasn't controlled. Introducing IClock and FrozenClock made the test deterministic regardless of when it ran.


Key Takeaway

Static mutable state causes test interference — tests share the same memory and affect each other. Static method calls to I/O (database, HTTP, clock) are hidden dependencies you cannot substitute in tests. Replace static I/O access with injected interfaces. Use IClock instead of DateTime.UtcNow. Pure static functions (no state, no I/O) are testable and acceptable. Service locators are static-dependency antipatterns — always prefer constructor injection.