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
- Working Effectively with Legacy Code by Michael Feathers - The definitive guide
- Growing Object-Oriented Software, Guided by Tests - Test-driven development principles
- xUnit Test Patterns - Comprehensive testing patterns catalog
- ApprovalTests - Framework for approval testing