Introduction
Test-Driven Development and Clean Architecture complement each other perfectly. This article explores how to combine these practices to create maintainable and testable software.
Clean Architecture Principles
Clean Architecture provides a way to structure applications with clear boundaries and dependencies:
- Independence of frameworks
- Testability
- Independence of UI
- Independence of database
- Independence of any external agency
Layers in Clean Architecture
1. Entities (Domain Layer)
// User entity
class User {
constructor(id, name, email) {
this.id = id;
this.name = name;
this.email = email;
}
validateEmail() {
return /^[^s@]+@[^s@]+.[^s@]+$/.test(this.email);
}
}
// Test for User entity
test('should validate correct email', () => {
const user = new User(1, 'John', 'john@example.com');
expect(user.validateEmail()).toBe(true);
});
2. Use Cases (Application Layer)
class CreateUserUseCase {
constructor(userRepository) {
this.userRepository = userRepository;
}
async execute(userData) {
const user = new User(null, userData.name, userData.email);
if (!user.validateEmail()) {
throw new Error('Invalid email');
}
return this.userRepository.save(user);
}
}
// Test for CreateUserUseCase
test('should create user with valid data', async () => {
const mockRepository = {
save: jest.fn().mockResolvedValue({ id: 1, name: 'John', email: 'john@example.com' })
};
const useCase = new CreateUserUseCase(mockRepository);
const result = await useCase.execute({ name: 'John', email: 'john@example.com' });
expect(result).toHaveProperty('id');
expect(mockRepository.save).toHaveBeenCalled();
});
Testing Strategy
1. Unit Tests
- Test entities and use cases in isolation
- Mock external dependencies
- Focus on business logic
2. Integration Tests
- Test interaction between layers
- Use test doubles for external services
- Verify correct data flow
Example: User Registration Flow
// Domain Layer
class User {
constructor(id, name, email, password) {
this.id = id;
this.name = name;
this.email = email;
this.password = password;
}
validate() {
if (!this.name || !this.email || !this.password) {
throw new Error('Missing required fields');
}
if (!this.validateEmail()) {
throw new Error('Invalid email format');
}
if (this.password.length < 8) {
throw new Error('Password too short');
}
}
}
// Application Layer
class RegisterUserUseCase {
constructor(userRepository, passwordHasher) {
this.userRepository = userRepository;
this.passwordHasher = passwordHasher;
}
async execute(userData) {
const hashedPassword = await this.passwordHasher.hash(userData.password);
const user = new User(null, userData.name, userData.email, hashedPassword);
user.validate();
return this.userRepository.save(user);
}
}
// Test
describe('RegisterUserUseCase', () => {
test('should register user with valid data', async () => {
const mockRepository = { save: jest.fn() };
const mockHasher = { hash: jest.fn().mockResolvedValue('hashed') };
const useCase = new RegisterUserUseCase(mockRepository, mockHasher);
const result = await useCase.execute({
name: 'John',
email: 'john@example.com',
password: 'password123'
});
expect(mockHasher.hash).toHaveBeenCalledWith('password123');
expect(mockRepository.save).toHaveBeenCalled();
expect(result).toHaveProperty('id');
});
});
Benefits of Combining TDD with Clean Architecture
- Clear separation of concerns
- Easy to test business logic
- Flexible and maintainable code
- Reduced technical debt
Conclusion
By combining TDD with Clean Architecture, you can create software that is both well-tested and well-structured. This approach leads to more maintainable and scalable applications.
"The goal of software architecture is to minimize the human resources required to build and maintain the required system." - Robert C. Martin
Member discussion: