Breaking the catch-22: How to safely add tests to untested code
You can't refactor safely without tests.
You can't add tests without refactoring.
This is the fundamental problem of legacy code modernization.
Before you can use:
Testing is the foundation. Everything else builds on it.
Characterization tests capture what the code actually does, not what it should do.
Key differences from normal tests:
// 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.
Also called Approval Testing or Snapshot Testing.
The technique:
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.
A seam is a place where you can alter behavior without editing that place.
Types of seams:
// 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)
}
}
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
}
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
}
}
Capital One's Legacy Modernization:
Key insight: Perfect coverage isn't the goal - confidence is.
Approval Testing:
Mocking External Services:
Coverage Analysis:
DO:
DON'T:
Test and refactor when:
Rewrite when:
With tests in place, you can safely:
Tests are the safety net for everything else.
Start small. Build your safety net. Then refactor with confidence.