Learnixo

Test-Driven Development in C# · Lesson 5 of 6

TDD with Legacy Code — Adding Tests to Untested Code

The Legacy Code Problem

Legacy code: code without tests.
(Michael Feathers' definition — age is irrelevant)

The vicious cycle:
  → Can't change legacy code safely without tests
  → Can't add tests without changing legacy code (to inject dependencies)
  → Therefore: nothing can be changed safely

Breaking the cycle requires:
  1. Understand what the code currently does (characterisation tests)
  2. Find seams — places where behaviour can be changed without editing the code
  3. Extract dependencies behind interfaces
  4. Write tests that pin the existing behaviour
  5. Refactor and extend safely under test

Step 1 — Characterisation Tests

C#
// Don't understand what the legacy code does? Write a test that calls it and observe.
// You're not testing desired behaviour — you're documenting actual behaviour.

// Legacy class (no tests, unknown behaviour):
public class PrescriptionApprovalService
{
    public bool ApprovePrescription(int prescriptionId, double inrValue)
    {
        // 200 lines of SQL, business logic, email sending mixed together
        // ...
    }
}

// Characterisation test — pin what it actually does:
[Fact]
public void ApprovePrescription_WithInrOf2Point5_ReturnsTrue()
{
    var service = new PrescriptionApprovalService();
    // WARNING: this calls real DB and sends real emails
    // Fix the seams first — see Step 2

    var result = service.ApprovePrescription(testPrescriptionId, 2.5);

    // Whatever it returns — document it.
    // If it returns true, the test asserts true.
    // You are NOT deciding what's correct — you're pinning current behaviour.
    result.Should().BeTrue();
}

// When the test fails after a change: you changed behaviour.
// Decide: was that intentional? Or a regression?

Step 2 — Finding and Exploiting Seams

C#
// A seam is a place where you can alter behaviour without editing the code.
// The most common seam in .NET: a virtual method or an injected dependency.

// Legacy code (no seam — impossible to test without hitting real DB):
public class PrescriptionApprovalService
{
    public bool ApprovePrescription(int prescriptionId, double inrValue)
    {
        using var conn = new SqlConnection("Server=PRODDB;..."); // hardcoded!
        // ...
        SendApprovalEmail(prescriptionId); // hardcoded SMTP
        return true;
    }

    private void SendApprovalEmail(int prescriptionId) { /* real SMTP */ }
}

// Adding a seam — extract the dependency without changing observable behaviour:
public class PrescriptionApprovalService
{
    private readonly IDbConnectionFactory _dbFactory;
    private readonly IEmailSender         _emailSender;

    // New constructor for injection (existing callers still use parameterless constructor)
    public PrescriptionApprovalService(
        IDbConnectionFactory dbFactory,
        IEmailSender emailSender)
    {
        _dbFactory   = dbFactory;
        _emailSender = emailSender;
    }

    // Parameterless constructor for backward compat (legacy callers)
    public PrescriptionApprovalService()
        : this(new DefaultDbConnectionFactory(), new SmtpEmailSender()) { }
}

// Now testable:
var service = new PrescriptionApprovalService(
    Substitute.For<IDbConnectionFactory>(),
    Substitute.For<IEmailSender>());

Step 3 — Sprout Method

C#
// Sprout: add a new testable method instead of modifying the untested legacy method
// The legacy method stays untouched — you "sprout" new tested behaviour alongside it

// Legacy method (DO NOT TOUCH — no tests):
public bool ApprovePrescription(int prescriptionId, double inrValue)
{
    // 200 untested lines
}

// New requirement: INR must be checked within 24 hours.
// Instead of modifying the legacy method (risky), sprout a new method:

public bool IsInrCheckValid(double inrValue, DateTime checkedAt)
{
    return checkedAt >= DateTime.UtcNow.AddHours(-24)
        && inrValue >= 2.0
        && inrValue <= 3.0;
}

// Write tests for the new method:
[Fact]
public void IsInrCheckValid_RecentInrInRange_ReturnsTrue()
{
    var service = new PrescriptionApprovalService();
    var result  = service.IsInrCheckValid(2.5, DateTime.UtcNow.AddHours(-1));
    result.Should().BeTrue();
}

// Then call the new method FROM the legacy method — minimal change to legacy:
public bool ApprovePrescription(int prescriptionId, double inrValue)
{
    if (!IsInrCheckValid(inrValue, DateTime.UtcNow)) // ← one line added
        return false;

    // existing 200 untested lines follow
}

Step 4 — Strangler Fig Pattern

Strangler Fig: gradually replace a legacy system by routing new traffic
to new code alongside the old code, until the old code can be deleted.

Applied to a method/class (not just full systems):

Phase 1: New implementation alongside old
  → Create PrescriptionApprovalServiceV2 with full test coverage
  → Deploy both — V2 handles new prescriptions, V1 handles existing ones

Phase 2: Route traffic to new
  → Feature flag: "use_v2_approval_service": true
  → Monitor: V2 handles 100% of new prescriptions
  → V1 no longer receives new calls

Phase 3: Delete old
  → Remove PrescriptionApprovalServiceV1 and its tests
  → Only V2 remains — fully tested, clean

This is safer than "big bang" rewrites:
  → If V2 has a bug, disable the feature flag — immediate rollback
  → No downtime, no risky overnight cutover

Golden Master Testing for Complex Legacy Output

C#
// When you cannot understand what the legacy code does —
// capture its output and use it as the "golden master"

[Fact]
public void GeneratePrescriptionReport_ProducesExpectedOutput()
{
    // Arrange: known input
    var input = PrescriptionReportInput.FromFixture("warfarin-patient-001");

    // Act: call legacy report generator
    var legacyService = new LegacyPrescriptionReportService();
    var output = legacyService.GenerateReport(input);

    // Assert: matches the golden master file
    // First run: create the golden master file
    // Subsequent runs: compare against it
    var expected = File.ReadAllText("TestData/golden/prescription-report-001.txt");
    output.Should().Be(expected);
}

// When you change the code, the golden master test tells you if output changed.
// If output changed intentionally: update the golden master file.
// If output changed unexpectedly: regression caught.

Production issue I've seen: A 7-year-old clinical prescription system had no tests. A developer was asked to add a new approval workflow for high-risk medications. They modified the existing ApprovePrescription method directly. The method had 15 callers across 8 modules. The change was released and within 6 hours, nurses reported that standard Warfarin approvals were failing. The developer had not realised a condition path had changed. Rollback took 4 hours. Using the Sprout Method — adding a tested ApproveHighRiskPrescription alongside the existing untested method — would have made the new path fully tested and the existing path completely unchanged.


Key Takeaway

Legacy code without tests cannot be safely changed. Start with characterisation tests to pin current behaviour. Create seams by extracting dependencies behind interfaces. Use the Sprout Method to add new tested behaviour alongside legacy code without touching it. Apply the Strangler Fig to gradually replace entire classes. Never start with a big-bang rewrite — add tests incrementally, replace incrementally, always maintain a green test suite at every step.