Testing Legacy Code Without Tests

One of the biggest challenges in legacy modernization is that the code you most need to change often has no tests. Testing untested code seems impossible—you can't refactor safely without tests, but you can't add tests without refactoring the code to make it testable. This lesson breaks that cycle.

Michael Feathers' "Working Effectively with Legacy Code" offers proven techniques for safely adding tests to untested codebases, enabling confident refactoring and modernization.

Video Summary

The video presentation walks through practical strategies for testing legacy code:

  • Why legacy code lacks tests and why that's a problem
  • The paradox: can't refactor without tests, can't test without refactoring
  • Characterization tests: documenting existing behavior before changing it
  • Finding and exploiting seams to break dependencies
  • Techniques for making untestable code testable
  • Approval testing for complex outputs
  • Building confidence incrementally through test coverage

Key Concepts

1. The Legacy Code Dilemma

The catch-22:

  • To refactor safely, you need tests
  • To add tests, you need to refactor
  • But refactoring without tests is unsafe!

The solution: Break the cycle by making small, surgical changes to create testability, then adding tests before larger refactorings.

2. Characterization Tests: Document Current Behavior

Characterization tests don't test whether code is "correct"—they test what the code actually does right now.

// Legacy code with unclear behavior
function calculateDiscount(customer: any, items: any[]): number {
  let discount = 0;
  if (customer.vip) {
    discount = 0.15;
  }
  if (items.length > 10) {
    discount += 0.05;
  }
  if (customer.memberSince < new Date('2020-01-01')) {
    discount += 0.02;
  }
  return Math.min(discount, 0.3);
}

// Characterization test: capture current behavior
describe('calculateDiscount', () => {
  it('should calculate discount for VIP with many items', () => {
    const customer = { vip: true, memberSince: new Date('2019-01-01') };
    const items = Array(15).fill({});

    const discount = calculateDiscount(customer, items);

    // We're not saying this is correct, just that it's what happens now
    expect(discount).toBe(0.22); // 0.15 + 0.05 + 0.02
  });

  it('should cap discount at 30%', () => {
    const customer = { vip: true, memberSince: new Date('2010-01-01') };
    const items = Array(20).fill({});

    const discount = calculateDiscount(customer, items);

    expect(discount).toBe(0.3); // Capped at max
  });
});

Benefits:

  • Establishes baseline behavior before changes
  • Catches regressions when refactoring
  • Documents actual behavior (which may differ from intended)
  • Enables safe refactoring once tests are in place

3. Start Small: Test What You Can

Don't try to test everything at once. Start with:

Pure functions (easiest to test):

// This function is already testable
function formatPrice(amount: number, currency: string): string {
  return new Intl.NumberFormat('en-US', {
    style: 'currency',
    currency,
  }).format(amount);
}

// Easy to test
expect(formatPrice(99.99, 'USD')).toBe('$99.99');

Functions with minimal dependencies:

// Has one dependency, still manageable
function calculateShipping(weight: number, distance: number): number {
  const baseRate = getBaseRate(); // Single dependency
  return baseRate * weight * distance;
}

// Test by stubbing dependency
const originalGetBaseRate = getBaseRate;
beforeEach(() => {
  global.getBaseRate = () => 0.5;
});
afterEach(() => {
  global.getBaseRate = originalGetBaseRate;
});

4. Break Dependencies to Enable Testing

Problem: Untestable code with hard dependencies

class OrderProcessor {
  processOrder(orderId: string) {
    // Hard dependency on database
    const order = database.query('SELECT * FROM orders WHERE id = ?', [orderId]);

    // Hard dependency on payment service
    const payment = PaymentService.charge(order.amount);

    // Hard dependency on email service
    EmailService.send(order.customerEmail, 'Order confirmed');

    return { success: true };
  }
}

// How do you test this without hitting real DB, payment service, and email?

Solution: Dependency injection

class OrderProcessor {
  constructor(
    private db: Database,
    private paymentService: PaymentService,
    private emailService: EmailService
  ) {}

  processOrder(orderId: string) {
    const order = this.db.query('SELECT * FROM orders WHERE id = ?', [orderId]);
    const payment = this.paymentService.charge(order.amount);
    this.emailService.send(order.customerEmail, 'Order confirmed');
    return { success: true };
  }
}

// Now testable with mocks
const mockDb = { query: jest.fn() };
const mockPayment = { charge: jest.fn() };
const mockEmail = { send: jest.fn() };

const processor = new OrderProcessor(mockDb, mockPayment, mockEmail);

5. Exploit Seams

A "seam" is a place where you can alter behavior without modifying the code itself. (More on this in the dedicated Seams lesson.)

Object seam (method override):

class LegacyReportGenerator {
  generateReport() {
    const data = this.fetchDataFromLegacySystem(); // Hard to test
    return this.formatReport(data);
  }

  fetchDataFromLegacySystem() {
    // Complex, slow, requires real infrastructure
  }

  formatReport(data: any) {
    // This is what we actually want to test
  }
}

// Test by subclassing
class TestableReportGenerator extends LegacyReportGenerator {
  fetchDataFromLegacySystem() {
    return { /* test data */ };
  }
}

// Now we can test report formatting
const generator = new TestableReportGenerator();
const report = generator.generateReport();
expect(report).toContain('Expected content');

Real-World Applications

Example 1: Adding Tests to Legacy Express Route

Before: Untestable

app.post('/api/orders', (req, res) => {
  const order = req.body;

  // Direct database access
  db.query('INSERT INTO orders VALUES (?, ?)', [order.id, order.total]);

  // Direct external API call
  axios.post('https://payment.example.com/charge', {
    amount: order.total,
  });

  // Direct email sending
  sendEmail(order.customerEmail, 'Order received');

  res.json({ success: true });
});

After: Testable

// Extract business logic
class OrderService {
  constructor(
    private db: Database,
    private paymentService: PaymentService,
    private emailService: EmailService
  ) {}

  async createOrder(order: Order) {
    await this.db.query('INSERT INTO orders VALUES (?, ?)', [
      order.id,
      order.total,
    ]);

    await this.paymentService.charge(order.total);

    await this.emailService.send(order.customerEmail, 'Order received');

    return { success: true };
  }
}

// Route becomes thin controller
app.post('/api/orders', async (req, res) => {
  const result = await orderService.createOrder(req.body);
  res.json(result);
});

// Business logic is now testable
describe('OrderService', () => {
  it('should create order and send email', async () => {
    const mockDb = { query: jest.fn() };
    const mockPayment = { charge: jest.fn() };
    const mockEmail = { send: jest.fn() };

    const service = new OrderService(mockDb, mockPayment, mockEmail);

    await service.createOrder({ id: '123', total: 99.99, customerEmail: 'test@example.com' });

    expect(mockDb.query).toHaveBeenCalled();
    expect(mockPayment.charge).toHaveBeenCalledWith(99.99);
    expect(mockEmail.send).toHaveBeenCalledWith('test@example.com', 'Order received');
  });
});

Example 2: Testing Code with Global State

Problem: Code depends on global variables

let currentUser: User | null = null;

function processUserAction(action: Action) {
  if (!currentUser) {
    throw new Error('No user logged in');
  }

  if (!currentUser.hasPermission(action.type)) {
    throw new Error('Permission denied');
  }

  return executeAction(action);
}

Solution: Inject dependency or use seam

// Option 1: Inject user
function processUserAction(action: Action, user: User) {
  if (!user.hasPermission(action.type)) {
    throw new Error('Permission denied');
  }

  return executeAction(action);
}

// Now easily testable
const mockUser = { hasPermission: () => true };
processUserAction(action, mockUser);

// Option 2: If can't change signature, use seam
function getUserForTesting(): User | null {
  return currentUser;
}

function processUserAction(action: Action) {
  const user = getUserForTesting();
  if (!user) {
    throw new Error('No user logged in');
  }
  // ...
}

// In tests, override the seam
global.getUserForTesting = () => mockUser;

Example 3: Snapshot/Approval Testing for Complex Outputs

When outputs are large or complex, use approval testing:

function generateInvoiceHTML(order: Order): string {
  // Generates 500 lines of HTML
  // Too complex to verify line by line
}

// Approval test: verify entire output
it('should generate invoice HTML', () => {
  const order = createTestOrder();
  const html = generateInvoiceHTML(order);

  // First run: saves snapshot
  // Future runs: compares to snapshot
  expect(html).toMatchSnapshot();
});

// When you intentionally change output, update snapshots
// jest -u

Common Pitfalls

1. Trying to Test Everything at Once

Problem: "We need 100% coverage before refactoring anything!"

Why it fails: Overwhelming, takes forever, team loses momentum.

Solution: Strategic coverage—focus on code you're about to change:

  • Adding feature? Test that module.
  • Fixing bug? Test that code path.
  • Refactoring? Test the interface boundary.

2. Tests That Test Implementation, Not Behavior

Bad: Tests know too much

it('should call helper methods in correct order', () => {
  const spy1 = jest.spyOn(service, 'validateInput');
  const spy2 = jest.spyOn(service, 'processData');

  service.doSomething(input);

  expect(spy1).toHaveBeenCalledBefore(spy2); // Brittle!
});

Good: Tests verify outcomes

it('should process valid input successfully', () => {
  const result = service.doSomething(validInput);

  expect(result.status).toBe('success');
  expect(result.data).toEqual(expectedData);
});

3. Excessive Mocking

Problem: Tests that mock everything teach you nothing:

// This test is useless
it('should work', () => {
  const mock = { process: jest.fn().mockReturnValue('success') };
  const result = mock.process();
  expect(result).toBe('success'); // Of course it is, you told it to!
});

Solution: Mock at boundaries, test real logic:

  • Mock external services (APIs, databases)
  • Test real business logic
  • Use real objects for simple dependencies

Key Takeaways

  • Legacy code without tests CAN be tested—start small and strategic
  • Characterization tests document existing behavior before changes
  • Break dependencies through refactoring to enable testing
  • Find and exploit seams to test code without major changes
  • Focus coverage on code you're actively changing
  • Test behavior and outcomes, not implementation details
  • Approval testing is valuable for complex outputs
  • Building confidence is incremental—perfect coverage isn't required

Further Reading