The surgical technique for making untestable legacy code testable
You have legacy code with:
new Database())Logger.getInstance())new Date() callsYou can't test it without changing it.
But changing it without tests is risky.
Michael Feathers' definition from Working Effectively with Legacy Code:
"A place where you can alter behavior in your program without editing in that place."
Every seam has an enabling point - where you decide which behavior to use.
The seam is IN the code. The enabling point is OUTSIDE.
Feathers identified three categories:
#ifdef)For TypeScript/JavaScript: Object seams are your primary tool.
Link seams are occasionally useful. Preprocessing seams rarely apply.
// Untestable: Hard-coded dependency
class OrderProcessor {
processOrder(order: Order) {
const payment = new PaymentGateway() // Can't mock this
payment.charge(order.total)
// What if charge() fails?
// What if we need to test without real payment?
}
}
Problem: No way to substitute test behavior.
// Testable: Dependency injection creates the seam
class OrderProcessor {
constructor(private payment: IPaymentGateway) {} // SEAM
processOrder(order: Order) {
this.payment.charge(order.total) // Behavior varies here
}
}
// ENABLING POINT (in test setup):
const mockPayment = new MockPaymentGateway()
const processor = new OrderProcessor(mockPayment)
The seam is in processOrder(). The enabling point is the constructor call.
// timeProvider.ts (production)
export function getCurrentTime(): Date {
return new Date()
}
// timeProvider.test.ts (test environment)
export function getCurrentTime(): Date {
return new Date('2024-01-01') // Fixed time
}
// Code under test
import { getCurrentTime } from './timeProvider'
function isExpired(expiryDate: Date): boolean {
return getCurrentTime() > expiryDate // SEAM
}
Enabling point: Build configuration or import path mapping.
// Step 1: Legacy singleton
class Logger {
static getInstance() { /* ... */ }
}
Database.query('SELECT...') // Static call everywhere
// Step 2: Extract interface
interface ILogger {
log(message: string): void
}
interface IDatabase {
query(sql: string): Result
}
// Step 3: Inject dependencies
class UserService {
constructor(
private logger: ILogger,
private db: IDatabase
) {} // Two seams created
}
1. Singleton preventing tests
Logger.getInstance().log() everywhereILogger, inject instance2. Static database calls
Database.query() makes tests slowIDatabaseConnection, use in-memory for tests3. Time-dependent logic
new Date() makes tests non-deterministicIClock with now() method4. Third-party API integration
IApiClient, mock in testsUse seams when:
Don't use seams when:
Seams are often temporary stepping stones, not permanent solutions.
"Seams = Dependency Injection"
"Seams are only for testing"
"Need to refactor entire codebase"
"Seams require interfaces in all languages"
"Seams are permanent"
Seams enable and relate to other patterns:
Understanding seams helps you recognize these patterns in the wild.
Step 1: Find untestable code with hard dependencies Step 2: Identify where you need to vary behavior (the seam location) Step 3: Extract an interface or create abstraction Step 4: Use constructor injection (object seam) Step 5: Write tests with mock implementations Step 6: Once tested, refactor confidently toward better design
Start small. One seam at a time. Build your test safety net.
Primary Source: Working Effectively with Legacy Code by Michael Feathers (2004)
Online Resources:
Next Steps: Apply seams to enable characterization tests, then use Strangler Fig for incremental modernization.
The seam concept is your key to unlocking legacy code.