Introduction
Michael Feathers' "Working Effectively with Legacy Code" provides strategies for improving and maintaining legacy codebases. This article explores key concepts and practical examples from the book.
Characterization Tests
Characterization tests help understand existing behavior before making changes:
describe('LegacyCalculator', () => {
test('should calculate total with tax', () => {
const calculator = new LegacyCalculator();
const result = calculator.calculateTotal(100, 0.1);
expect(result).toBe(110);
});
test('should handle zero tax', () => {
const calculator = new LegacyCalculator();
const result = calculator.calculateTotal(100, 0);
expect(result).toBe(100);
});
});
Breaking Dependencies
Techniques for breaking dependencies in legacy code:
// Original code with dependency
class OrderProcessor {
constructor() {
this.database = new Database();
}
processOrder(order) {
return this.database.save(order);
}
}
// Refactored code with dependency injection
class OrderProcessor {
constructor(database) {
this.database = database;
}
processOrder(order) {
return this.database.save(order);
}
}
// Test with mock
test('should save order to database', () => {
const mockDatabase = {
save: jest.fn().mockResolvedValue({ id: 1 })
};
const processor = new OrderProcessor(mockDatabase);
const order = { items: [] };
processor.processOrder(order);
expect(mockDatabase.save).toHaveBeenCalledWith(order);
});
Seam Types
- Preprocessing Seams
- Use preprocessor directives
- Conditional compilation
- Link Seams
- Replace implementation at link time
- Use dependency injection
- Object Seams
- Use polymorphism
- Implement interfaces
Example: Adding Tests to Legacy Code
// Legacy code
class LegacyUserService {
constructor() {
this.database = new Database();
this.logger = new Logger();
}
createUser(userData) {
try {
const user = this.database.save(userData);
this.logger.log('User created: ' + user.id);
return user;
} catch (error) {
this.logger.error('Error creating user: ' + error.message);
throw error;
}
}
}
// Refactored code with tests
class UserService {
constructor(database, logger) {
this.database = database;
this.logger = logger;
}
createUser(userData) {
try {
const user = this.database.save(userData);
this.logger.log('User created: ' + user.id);
return user;
} catch (error) {
this.logger.error('Error creating user: ' + error.message);
throw error;
}
}
}
// Tests
describe('UserService', () => {
test('should create user and log success', () => {
const mockDatabase = {
save: jest.fn().mockReturnValue({ id: 1 })
};
const mockLogger = {
log: jest.fn(),
error: jest.fn()
};
const service = new UserService(mockDatabase, mockLogger);
const userData = { name: 'John' };
const user = service.createUser(userData);
expect(user).toHaveProperty('id', 1);
expect(mockLogger.log).toHaveBeenCalledWith('User created: 1');
});
test('should log error when database fails', () => {
const mockDatabase = {
save: jest.fn().mockImplementation(() => {
throw new Error('Database error');
})
};
const mockLogger = {
log: jest.fn(),
error: jest.fn()
};
const service = new UserService(mockDatabase, mockLogger);
const userData = { name: 'John' };
expect(() => service.createUser(userData))
.toThrow('Database error');
expect(mockLogger.error)
.toHaveBeenCalledWith('Error creating user: Database error');
});
});
Best Practices for Legacy Code
- Write characterization tests first
- Break dependencies gradually
- Use seams to introduce testability
- Refactor in small, safe steps
- Maintain existing behavior
Conclusion
Michael Feathers' approach to legacy code emphasizes understanding, safety, and gradual improvement. By following these principles, we can make legacy code more maintainable and reliable.
"Legacy code is simply code without tests." - Michael Feathers
Member discussion: