| name | legacy |
| description | Legacy code testing patterns from Working Effectively with Legacy Code |
Michael Feathers - Legacy Code Testing
Apply Michael Feathers' techniques for testing and refactoring legacy code.
Core Philosophy
The Definition of Legacy Code
"Legacy code is code without tests."
It doesn't matter how old or how clean—if it lacks tests, it's legacy code.
The Legacy Code Dilemma
"When we change code, we should have tests in place. To put tests in place, we often have to change code."
Solution: Use safe, mechanical refactorings to create seams for testing.
Characterization Tests
Purpose
Characterization tests document what the code actually does, not what it should do.
Pattern
@Test
void characterize_calculateDiscount() {
Order order = new Order();
order.setTotal(100.0);
order.setCustomerType("GOLD");
double result = discountCalculator.calculate(order);
assertEquals(15.0, result, 0.001);
}
When to Use
- Before refactoring legacy code
- When documentation is missing or wrong
- To understand what code actually does
- Before fixing a bug (capture current behavior first)
The Rule
"Preserve behavior first, then change it."
Seams
A seam is a place where you can alter behavior without editing the code.
Types of Seams
1. Object Seam (most common)
public class OrderProcessor {
public void process(Order order) {
EmailService emailer = new EmailService();
emailer.send(order.getCustomerEmail(), "Order received");
}
}
public class OrderProcessor {
private final EmailService emailer;
public OrderProcessor(EmailService emailer) {
this.emailer = emailer;
}
public void process(Order order) {
emailer.send(order.getCustomerEmail(), "Order received");
}
}
@Test
void process_sendsEmail() {
FakeEmailService fakeEmail = new FakeEmailService();
OrderProcessor processor = new OrderProcessor(fakeEmail);
processor.process(testOrder);
assertTrue(fakeEmail.wasSentTo("customer@test.com"));
}
2. Link Seam
Replace a dependency at link/build time.
3. Preprocessing Seam
#ifdef TESTING
#define getCurrentTime() mockTime
#else
#define getCurrentTime() time(NULL)
#endif
The Seam Principle
"Every seam has an enabling point—a place where you can make the decision to use one behavior or another."
Breaking Dependencies
Technique: Extract and Override
public class Scheduler {
public boolean isOverdue(Task task) {
Date now = new Date();
return task.getDueDate().before(now);
}
}
public class Scheduler {
public boolean isOverdue(Task task) {
Date now = getCurrentTime();
return task.getDueDate().before(now);
}
protected Date getCurrentTime() {
return new Date();
}
}
class TestableScheduler extends Scheduler {
private Date fixedTime;
public void setCurrentTime(Date time) {
this.fixedTime = time;
}
@Override
protected Date getCurrentTime() {
return fixedTime;
}
}
Technique: Parameterize Constructor
public class Report {
private Database db = Database.getInstance();
public List<Row> generate() {
return db.query("SELECT * FROM data");
}
}
public class Report {
private final Database db;
public Report() {
this(Database.getInstance());
}
public Report(Database db) {
this.db = db;
}
public List<Row> generate() {
return db.query("SELECT * FROM data");
}
}
Technique: Introduce Instance Delegator
public class Validator {
public static boolean isValid(String input) {
return Pattern.matches("[A-Z]+", input);
}
}
public class Validator {
public static boolean isValid(String input) {
return new Validator().validate(input);
}
public boolean validate(String input) {
return Pattern.matches("[A-Z]+", input);
}
}
Sprout and Wrap
Sprout Method
When adding new functionality to legacy code:
public void processOrder(Order order) {
if (!isValidOrder(order)) {
throw new InvalidOrderException();
}
}
@Test
void isValidOrder_rejectsEmptyItems() {
Order order = new Order();
assertFalse(isValidOrder(order));
}
Sprout Class
When the new functionality deserves its own class:
public class OrderProcessor {
public void process(Order order) {
OrderValidator validator = new OrderValidator();
validator.validate(order);
}
}
public class OrderValidator {
public void validate(Order order) {
}
}
Wrap Method
When you need to add behavior before/after existing code:
public void pay(Employee employee, Money amount) {
employee.addToBalance(amount);
}
public void pay(Employee employee, Money amount) {
logPayment(employee, amount);
dispatchPay(employee, amount);
}
private void dispatchPay(Employee employee, Money amount) {
employee.addToBalance(amount);
}
private void logPayment(Employee employee, Money amount) {
}
The Mikado Method
For large-scale legacy refactoring:
- Start with the goal - what do you want to achieve?
- Try the naive approach - just make the change
- When it breaks - note what broke, revert
- Fix prerequisites first - recursively
- Work backwards - solve leaf problems first
Goal: Extract OrderValidator class
├── Need to inject dependencies
│ ├── OrderProcessor uses singleton → Parameterize constructor
│ └── Database uses static → Extract interface
└── Need to separate validation logic
└── Validation mixed with persistence → Extract method first
Code Review Checklist
When working with legacy code:
Before Changing
When Adding Features
When Refactoring
Dependencies
Quick Reference
| Situation | Technique |
|---|
| Don't know what code does | Characterization test |
| Need to test untestable code | Find/create seam |
| Adding new feature to legacy | Sprout method/class |
| Adding behavior to existing method | Wrap method |
| Hard-coded dependency | Parameterize constructor |
| Static method dependency | Introduce instance delegator |
| Large legacy refactoring | Mikado method |
Key Quotes
"Dependency is one of the most critical problems in software development."
"Programming is the art of doing one thing at a time."
"Legacy code is code without tests. Code without tests is bad code."
Resources