Lesson 48 of 54

Refactoring Legacy Code

Working with Untested Code

I inherited a 2,000-line module with zero tests. The original author had left. The code was brittle. Every change risked breaking something nobody understood.

The dilemma: I couldn't refactor without tests. I couldn't add tests without refactoring. The code had no seams—no places to inject behavior or isolate logic.

This is the legacy code trap. Here's how to escape it.

The Legacy Code Dilemma

Untested code creates a vicious cycle:

  • Code is hard to test (tightly coupled, no seams)
  • Without tests, refactoring is risky
  • Without refactoring, the code stays hard to test

You need a wedge. A way to add safety without requiring a full rewrite.

Characterization Tests

Characterization tests document current behavior. They don't assert what the code should do—they assert what it does do. Right now.

The goal: capture the existing behavior before you change anything. Then refactor with confidence. If a characterization test breaks, you changed behavior (either intentionally or by mistake).

Example

Javascript
// Legacy function—nobody knows all the edge cases
function calculateDiscount(customer, order, promoCode) {
  // 150 lines of nested conditionals
  // ...
}

// Characterization test: document current behavior
describe('calculateDiscount (characterization)', () => {
  it('returns 0 for new customer with no promo', () => {
    const result = calculateDiscount(
      { type: 'new', loyaltyYears: 0 },
      { total: 100 },
      null
    );
    expect(result).toBe(0); // Whatever it actually returns
  });

  it('returns 10 for LOYAL20 promo on $50 order', () => {
    const result = calculateDiscount(
      { type: 'returning', loyaltyYears: 2 },
      { total: 50 },
      'LOYAL20'
    );
    expect(result).toBe(10); // Run the code, see what it returns, lock it in
  });
});

You run the code. You see what it returns. You write the assertion. You're not designing—you're documenting.

Trade-off: Characterization tests can lock in bugs. If the current behavior is wrong, your test will assert the wrong thing. That's acceptable when the alternative is no safety at all. Fix the bug in a separate change, then update the test.

The Sprout Technique

Sprout: Add new behavior in a new method. Call it from the old code. Don't modify the old code's structure yet.

When you need to add a feature to untested code, don't refactor the whole thing. Sprout a new piece.

Before

Javascript
function processOrder(order) {
  // 80 lines of existing logic
  const total = calculateTotal(order);
  chargePayment(order.paymentMethod, total);
  sendConfirmation(order.customer.email);
  // ...
}

After (sprout)

Javascript
function processOrder(order) {
  // 80 lines of existing logic—unchanged
  const total = calculateTotal(order);
  chargePayment(order.paymentMethod, total);
  sendConfirmation(order.customer.email);

  // NEW: Sprouted behavior—easy to test in isolation
  if (shouldNotifyLoyaltyProgram(order)) {
    updateLoyaltyPoints(order.customer.id, total);
  }
  // ...
}

function shouldNotifyLoyaltyProgram(order) {
  return order.customer.loyaltyTier !== 'none' && order.total > 25;
}

function updateLoyaltyPoints(customerId, amount) {
  // New, testable logic
}

The new behavior lives in clean, testable functions. The old code gets one new line. You've added a feature without destabilizing the legacy blob.

The Wrap Technique

Wrap: Wrap the old code with a new interface. The old code stays untouched. The new code provides a cleaner API.

Useful when you need to replace behavior gradually or add cross-cutting concerns (logging, validation) without touching the core.

Javascript
// Old, untested function
function fetchUserData(userId) {
  // 200 lines of database calls, caching, error handling
}

// Wrap it
function getUser(userId) {
  validateUserId(userId);  // New, testable
  const data = fetchUserData(userId);  // Old code—unchanged
  return mapToUserModel(data);  // New, testable
}

The wrapper is thin and testable. The legacy code runs as-is. Over time, you can move logic out of fetchUserData into the wrapper or new modules.

Adding Seams for Testability

A seam is a place where you can alter behavior without editing the code in that spot. Dependency injection creates seams.

Before: No Seam

Javascript
function sendOrderConfirmation(order) {
  const emailService = new SmtpEmailService();  // Hard-coded
  emailService.send(order.customer.email, `Order ${order.id} confirmed`);
}

You can't test this without sending real emails. There's no seam.

After: Seam via Parameter

Javascript
function sendOrderConfirmation(order, emailService = new SmtpEmailService()) {
  emailService.send(order.customer.email, `Order ${order.id} confirmed`);
}

// In tests
const mockEmail = { send: jest.fn() };
sendOrderConfirmation(order, mockEmail);
expect(mockEmail.send).toHaveBeenCalledWith('user@example.com', expect.any(String));

Default parameter preserves existing call sites. Tests inject a mock. One change, full testability.

After: Seam via Constructor (Class)

Typescript
class OrderProcessor {
  constructor(private emailService: EmailService = new SmtpEmailService()) {}

  process(order: Order) {
    // ...
    this.emailService.send(order.customer.email, template);
  }
}

Same idea. Production uses the real service. Tests inject a fake.

Incremental Testing Strategies

You don't need to test everything at once. Prioritize.

1. Test the Entry Points

Start with the public API—the functions that external code calls. Add characterization tests for the most common paths. These protect you when you refactor internals.

2. Test the Riskiest Parts

Which code handles money? Auth? Data integrity? Test that first. A bug in a discount calculation is annoying. A bug in payment processing is catastrophic.

3. Test as You Touch

When you need to change a function, add a test for it first. You're already reading the code. The test documents your understanding and protects your change.

4. Expand the Tested Surface Gradually

Each bug fix, each feature, each refactor—add a test. Over months, the tested surface grows. The legacy blob shrinks.

When You Can't Add Seams Easily

Sometimes the code is so tangled that adding a seam requires significant refactoring. In that case:

  1. Characterization test at the highest level you can. Even a slow, brittle integration test is better than nothing.
  2. Sprout new behavior instead of modifying the old.
  3. Consider a strangler fig (next lesson)—build new code around the old, and migrate gradually.

Don't let perfect be the enemy of good. A few characterization tests and some sprouted code can dramatically reduce risk.


Key insight: Legacy code traps you: no tests, no refactoring; no refactoring, no tests. Break the cycle with characterization tests (document current behavior), the Sprout technique (add new code without touching old), and seams (dependency injection). Test incrementally—entry points first, then the riskiest parts. One step at a time.