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