Introduction
Test-Driven Development and SOLID principles complement each other perfectly. This article explores how TDD naturally leads to SOLID code and how SOLID principles make code more testable.
Single Responsibility Principle (SRP)
TDD encourages SRP by making us think about behavior in small, focused units:
// Bad: Multiple responsibilities
class UserManager {
createUser(userData) {
// Validate user data
// Hash password
// Save to database
// Send welcome email
}
}
// Good: Single responsibility
class UserValidator {
validate(userData) {
// Validation logic
}
}
class PasswordHasher {
hash(password) {
// Hashing logic
}
}
class UserRepository {
save(user) {
// Database operations
}
}
class EmailService {
sendWelcome(user) {
// Email sending logic
}
}
// Tests are focused and clear
describe('UserValidator', () => {
test('should validate user data', () => {
const validator = new UserValidator();
expect(validator.validate({ name: 'John', email: 'john@example.com' }))
.toBe(true);
});
});
describe('PasswordHasher', () => {
test('should hash password', () => {
const hasher = new PasswordHasher();
const hashed = hasher.hash('password123');
expect(hashed).not.toBe('password123');
});
});
Open/Closed Principle (OCP)
TDD helps us design extensible code through the Open/Closed Principle:
// Base class
class PaymentProcessor {
process(amount) {
throw new Error('Not implemented');
}
}
// Extensions
class CreditCardProcessor extends PaymentProcessor {
process(amount) {
// Process credit card payment
}
}
class PayPalProcessor extends PaymentProcessor {
process(amount) {
// Process PayPal payment
}
}
// Tests for each implementation
describe('PaymentProcessors', () => {
test('should process credit card payment', () => {
const processor = new CreditCardProcessor();
expect(processor.process(100)).toBe(true);
});
test('should process PayPal payment', () => {
const processor = new PayPalProcessor();
expect(processor.process(100)).toBe(true);
});
});
Liskov Substitution Principle (LSP)
TDD helps ensure that subtypes can be used interchangeably:
// Base class
class Shape {
area() {
throw new Error('Not implemented');
}
}
// Subtypes
class Rectangle extends Shape {
constructor(width, height) {
super();
this.width = width;
this.height = height;
}
area() {
return this.width * this.height;
}
}
class Square extends Shape {
constructor(side) {
super();
this.side = side;
}
area() {
return this.side * this.side;
}
}
// Tests verify LSP
describe('Shapes', () => {
test('should calculate rectangle area', () => {
const shape = new Rectangle(4, 5);
expect(shape.area()).toBe(20);
});
test('should calculate square area', () => {
const shape = new Square(4);
expect(shape.area()).toBe(16);
});
test('should work with any shape', () => {
const shapes = [
new Rectangle(4, 5),
new Square(4)
];
const areas = shapes.map(shape => shape.area());
expect(areas).toEqual([20, 16]);
});
});
Interface Segregation Principle (ISP)
TDD encourages focused interfaces through the Interface Segregation Principle:
// Bad: Large interface
interface Worker {
work();
eat();
sleep();
}
// Good: Segregated interfaces
interface Workable {
work();
}
interface Eatable {
eat();
}
interface Sleepable {
sleep();
}
// Tests are more focused
describe('Worker', () => {
test('should perform work', () => {
const worker = new HumanWorker();
expect(worker.work()).toBe('Working');
});
test('should eat', () => {
const worker = new HumanWorker();
expect(worker.eat()).toBe('Eating');
});
});
Dependency Inversion Principle (DIP)
TDD naturally leads to dependency inversion through mocking and dependency injection:
// High-level module
class OrderService {
constructor(paymentProcessor, notificationService) {
this.paymentProcessor = paymentProcessor;
this.notificationService = notificationService;
}
processOrder(order) {
const paymentResult = this.paymentProcessor.process(order.amount);
if (paymentResult.success) {
this.notificationService.sendConfirmation(order);
return { success: true };
}
return { success: false };
}
}
// Tests with dependencies
describe('OrderService', () => {
test('should process successful order', () => {
const mockPaymentProcessor = {
process: jest.fn().mockReturnValue({ success: true })
};
const mockNotificationService = {
sendConfirmation: jest.fn()
};
const service = new OrderService(
mockPaymentProcessor,
mockNotificationService
);
const result = service.processOrder({ amount: 100 });
expect(result.success).toBe(true);
expect(mockNotificationService.sendConfirmation)
.toHaveBeenCalled();
});
});
Best Practices
- Write tests that verify behavior, not implementation
- Use dependency injection to make code testable
- Keep classes focused and cohesive
- Design for extension, not modification
- Use interfaces to define contracts
Conclusion
TDD and SOLID principles work together to create maintainable, testable, and flexible code. By following these principles, we can build software that is easier to understand, modify, and extend.
"The best way to predict the future is to implement it." - Alan Kay
Member discussion: