Testing Legacy Code Without Tests

Breaking the catch-22: How to safely add tests to untested code

The Legacy Code Dilemma

You can't refactor safely without tests.

You can't add tests without refactoring.

This is the fundamental problem of legacy code modernization.

Why This Matters Now

Before you can use:

  • Strangler Fig - need tests to verify behavior matches
  • Branch by Abstraction - need tests to swap safely
  • Parallel Run - need tests to validate results
  • Feature Flags - need tests for all flag states

Testing is the foundation. Everything else builds on it.

What Are Characterization Tests?

Characterization tests capture what the code actually does, not what it should do.

Key differences from normal tests:

  • Document current behavior (bugs and all)
  • Written before understanding the code
  • Answer: "What does this do?" not "Is this correct?"

Writing Your First Characterization Test

// Step 1: Run the code and observe output
test('calculateDiscount behavior', () => {
  const result = calculateDiscount(100, 'GOLD')

  // Step 2: Record what actually happens
  expect(result).toBe(15) // Not sure if correct, but it's current
})

Don't fix bugs yet. Document behavior first.

Golden Master Testing

Also called Approval Testing or Snapshot Testing.

The technique:

  1. Generate many random inputs (same seed every time)
  2. Capture all outputs as a "golden master"
  3. Run tests - compare new output to golden master
  4. Any difference = test fails

Golden Master Example

import { verify } from 'approvals'

test('process order - golden master', () => {
  const orders = generateTestOrders(42) // Fixed seed
  const results = orders.map(o => processOrder(o))

  // Compare output to approved baseline
  verify(JSON.stringify(results, null, 2))
})

Perfect for complex outputs you don't fully understand yet.

Finding Seams in Legacy Code

A seam is a place where you can alter behavior without editing that place.

Types of seams:

  • Object seams - interfaces, dependency injection
  • Link seams - swap implementations via configuration
  • Preprocessing seams - conditional compilation (C/C++)

Creating Seams: Before & After

// Before: Hard to test (directly calls database)
class OrderProcessor {
  process(order) {
    const db = new DatabaseConnection()
    db.save(order)
  }
}

// After: Seam added (inject dependency)
class OrderProcessor {
  constructor(private db: Database) {}
  process(order) {
    this.db.save(order)
  }
}

Sprout Method Pattern

When you can't test existing code, write new code you can test.

// Legacy method (untestable mess)
function updateCustomerAccount(customerId, data) {
  // 200 lines of coupled code...
  const discount = calculateLoyaltyDiscount(data) // NEW
  // More coupled code...
}

// New sprouted method (fully testable)
function calculateLoyaltyDiscount(data) {
  return data.points > 1000 ? 0.15 : 0.05
}

Extract and Override

Subclass for testing to break dependencies.

class LegacyReportGenerator {
  protected getDatabaseConnection() {
    return new ProductionDB() // Can't test this
  }
  generate() {
    const db = this.getDatabaseConnection()
    return db.query('SELECT * FROM sales')
  }
}

// Test subclass
class TestableReportGenerator extends LegacyReportGenerator {
  protected getDatabaseConnection() {
    return new MockDB() // Override for testing
  }
}

Real-World Example

Capital One's Legacy Modernization:

  • Started with 0% test coverage on critical COBOL banking code
  • Used approval testing to capture transaction behavior
  • Added characterization tests for high-risk paths
  • Achieved 60% coverage on critical paths (not 100%)
  • Enabled safe refactoring and gradual migration to Java

Key insight: Perfect coverage isn't the goal - confidence is.

Test Harnesses and Tools

Approval Testing:

  • ApprovalTests (Java, C#, Ruby, JavaScript)
  • Jest Snapshots (JavaScript/TypeScript)
  • TextTest (Python, any language)

Mocking External Services:

  • WireMock (HTTP APIs)
  • Test doubles for databases
  • In-memory implementations

Coverage Analysis:

  • Istanbul/nyc, JaCoCo, Coverage.py

Best Practices and Pitfalls

DO:

  • Start with high-value, high-risk areas
  • Write tests BEFORE changing code
  • Document current behavior (bugs included)
  • Use approval tests for complex outputs

DON'T:

  • Try to achieve 100% coverage
  • Fix bugs while writing characterization tests
  • Refactor without tests in place
  • Rewrite entire systems without tests

When to Test vs When to Rewrite

Test and refactor when:

  • Business logic is complex and valuable
  • System is revenue-critical
  • Domain knowledge is embedded in code
  • You have time for incremental improvement

Rewrite when:

  • Code is truly impossible to test
  • Technology is completely obsolete
  • Rewrite is smaller scope than you think
  • You have complete requirements and tests for behavior

How Testing Enables Modernization

With tests in place, you can safely:

  1. Strangler Fig - Verify new system matches old behavior
  2. Branch by Abstraction - Swap implementations confidently
  3. Parallel Run - Compare outputs automatically
  4. Feature Flags - Test all flag combinations

Tests are the safety net for everything else.

Key Takeaways

  • The testing dilemma is real, but solvable with techniques
  • Characterization tests capture current behavior, not correct behavior
  • Seams let you alter behavior without editing code
  • Sprout new testable code alongside legacy code
  • Golden Master testing works when you don't understand the output
  • Start with high-value areas, not 100% coverage

Getting Started This Week

  1. Identify one high-risk, high-value legacy component
  2. Write characterization tests documenting current behavior
  3. Try Golden Master testing if output is complex
  4. Find or create seams for dependency injection
  5. Sprout new testable code for new features
  6. Measure confidence gained, not just coverage percentage

Start small. Build your safety net. Then refactor with confidence.

1 / 0
Testing Legacy Code Without Tests