← Volver al listado de tecnologías
Capítulo 8: Testing en Arquitectura Hexagonal
Capítulo 8: Testing en Arquitectura Hexagonal
Introducción
Una de las principales ventajas de la arquitectura hexagonal es su testabilidad. En este capítulo aprenderás a testear cada capa de forma independiente y efectiva.
1. Pirámide de Testing en Hexagonal
/\
/ \ E2E Tests
/----\ (Pocos, lentos, costosos)
/ \
/--------\ Integration Tests
/ \ (Moderados)
/------------\
/--------------\ Unit Tests
/________________\ (Muchos, rápidos, baratos)
Distribución Recomendada
- 70% Unit Tests: Dominio y casos de uso
- 20% Integration Tests: Adaptadores con infraestructura real
- 10% E2E Tests: Flujos completos de usuario
2. Testing del Dominio
Características
- ✅ Puros: Sin dependencias externas
- ✅ Rápidos: Milisegundos
- ✅ Aislados: Una entidad o VO a la vez
Ejemplo: Testing de Entidad
// domain/user/user.entity.ts
class User {
private constructor(
private readonly id: string,
private readonly email: Email,
private name: string,
private status: 'active' | 'suspended'
) {}
static create(id: string, email: Email, name: string): User {
return new User(id, email, name, 'active');
}
suspend(reason: string): void {
if (this.status === 'suspended') {
throw new Error('Usuario ya está suspendido');
}
this.status = 'suspended';
}
activate(): void {
this.status = 'active';
}
changeName(newName: string): void {
if (!newName || newName.trim().length === 0) {
throw new Error('Nombre no puede estar vacío');
}
this.name = newName;
}
get userStatus(): string {
return this.status;
}
}
// domain/user/user.entity.spec.ts
import { User } from './user.entity';
import { Email } from './email.vo';
describe('User Entity', () => {
describe('create', () => {
test('debería crear usuario con status activo', () => {
const email = Email.create('[email protected]');
const user = User.create('123', email, 'John Doe');
expect(user.userStatus).toBe('active');
});
});
describe('suspend', () => {
test('debería suspender usuario activo', () => {
const email = Email.create('[email protected]');
const user = User.create('123', email, 'John Doe');
user.suspend('Violación de términos');
expect(user.userStatus).toBe('suspended');
});
test('debería fallar al suspender usuario ya suspendido', () => {
const email = Email.create('[email protected]');
const user = User.create('123', email, 'John Doe');
user.suspend('Primera suspensión');
expect(() => user.suspend('Segunda suspensión')).toThrow(
'Usuario ya está suspendido'
);
});
});
describe('changeName', () => {
test('debería cambiar nombre válido', () => {
const email = Email.create('[email protected]');
const user = User.create('123', email, 'John Doe');
user.changeName('Jane Doe');
// Necesitaríamos getter para validar
});
test('debería fallar con nombre vacío', () => {
const email = Email.create('[email protected]');
const user = User.create('123', email, 'John Doe');
expect(() => user.changeName('')).toThrow('Nombre no puede estar vacío');
expect(() => user.changeName(' ')).toThrow('Nombre no puede estar vacío');
});
});
});
Testing de Value Objects
// domain/user/email.vo.spec.ts
import { Email } from './email.vo';
describe('Email Value Object', () => {
describe('create', () => {
test('debería crear email válido', () => {
const email = Email.create('[email protected]');
expect(email.value).toBe('[email protected]');
});
test('debería normalizar email a minúsculas', () => {
const email = Email.create('[email protected]');
expect(email.value).toBe('[email protected]');
});
test('debería fallar con email inválido', () => {
expect(() => Email.create('invalid')).toThrow('Email inválido');
expect(() => Email.create('')).toThrow('Email inválido');
expect(() => Email.create('test@')).toThrow('Email inválido');
});
});
describe('equals', () => {
test('debería ser igual con mismo valor', () => {
const email1 = Email.create('[email protected]');
const email2 = Email.create('[email protected]');
expect(email1.equals(email2)).toBe(true);
});
test('debería ser diferente con distinto valor', () => {
const email1 = Email.create('[email protected]');
const email2 = Email.create('[email protected]');
expect(email1.equals(email2)).toBe(false);
});
});
});
Testing de Agregados
// domain/order/order.aggregate.spec.ts
import { Order } from './order.aggregate';
describe('Order Aggregate', () => {
test('debería crear order vacía', () => {
const order = Order.create('customer-123');
expect(order.orderStatus).toBe('draft');
expect(order.total).toBe(0);
});
test('debería agregar items a order draft', () => {
const order = Order.create('customer-123');
order.addItem('product-1', 2, 10);
order.addItem('product-2', 1, 20);
expect(order.total).toBe(40);
});
test('no debería agregar items a order confirmada', () => {
const order = Order.create('customer-123');
order.addItem('product-1', 2, 10);
order.confirm();
expect(() => order.addItem('product-2', 1, 20)).toThrow(
'No se pueden agregar items a order confirmada'
);
});
test('no debería confirmar order vacía', () => {
const order = Order.create('customer-123');
expect(() => order.confirm()).toThrow(
'No se puede confirmar order vacía'
);
});
test('debería confirmar order con items', () => {
const order = Order.create('customer-123');
order.addItem('product-1', 2, 10);
order.confirm();
expect(order.orderStatus).toBe('confirmed');
});
});
3. Testing de Casos de Uso
Características
- ✅ Con mocks: Puertos secundarios mockeados
- ✅ Rápidos: Sin infraestructura real
- ✅ Enfocados: Un caso de uso a la vez
Ejemplo: Testing con Jest
// application/user/use-cases/create-user.usecase.spec.ts
import { CreateUserUseCase } from './create-user.usecase';
import { UserRepository } from '../ports/output/user-repository.port';
import { PasswordHasher } from '../ports/output/password-hasher.port';
import { EmailService } from '../ports/output/email-service.port';
describe('CreateUserUseCase', () => {
let useCase: CreateUserUseCase;
let mockUserRepo: jest.Mocked<UserRepository>;
let mockPasswordHasher: jest.Mocked<PasswordHasher>;
let mockEmailService: jest.Mocked<EmailService>;
beforeEach(() => {
// Crear mocks
mockUserRepo = {
save: jest.fn(),
findById: jest.fn(),
findByEmail: jest.fn()
};
mockPasswordHasher = {
hash: jest.fn()
};
mockEmailService = {
sendWelcomeEmail: jest.fn()
};
// Crear caso de uso con mocks
useCase = new CreateUserUseCase(
mockUserRepo,
mockPasswordHasher,
mockEmailService
);
});
test('debería crear usuario correctamente', async () => {
// Arrange
const command = {
email: '[email protected]',
name: 'John Doe',
password: 'password123'
};
mockUserRepo.findByEmail.mockResolvedValue(null);
mockPasswordHasher.hash.mockResolvedValue('hashed_password');
// Act
const result = await useCase.execute(command);
// Assert
expect(result.userId).toBeDefined();
expect(mockUserRepo.findByEmail).toHaveBeenCalledWith('[email protected]');
expect(mockPasswordHasher.hash).toHaveBeenCalledWith('password123');
expect(mockUserRepo.save).toHaveBeenCalled();
expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith(
'[email protected]',
'John Doe'
);
});
test('debería fallar si email ya existe', async () => {
const command = {
email: '[email protected]',
name: 'John Doe',
password: 'password123'
};
mockUserRepo.findByEmail.mockResolvedValue({
userId: 'existing-user'
} as any);
await expect(useCase.execute(command)).rejects.toThrow(
'Email ya registrado'
);
expect(mockUserRepo.save).not.toHaveBeenCalled();
expect(mockEmailService.sendWelcomeEmail).not.toHaveBeenCalled();
});
test('debería validar email inválido', async () => {
const command = {
email: 'invalid-email',
name: 'John Doe',
password: 'password123'
};
await expect(useCase.execute(command)).rejects.toThrow('Email inválido');
expect(mockUserRepo.findByEmail).not.toHaveBeenCalled();
});
test('debería validar password corto', async () => {
const command = {
email: '[email protected]',
name: 'John Doe',
password: 'short'
};
await expect(useCase.execute(command)).rejects.toThrow(
'Password debe tener al menos 8 caracteres'
);
});
});
Testing con Spies
// application/order/use-cases/place-order.usecase.spec.ts
import { PlaceOrderUseCase } from './place-order.usecase';
describe('PlaceOrderUseCase', () => {
test('debería verificar inventario antes de crear order', async () => {
const mockInventory = {
checkStock: jest.fn().mockResolvedValue(true),
reserve: jest.fn()
};
const mockOrderRepo = {
save: jest.fn()
};
const useCase = new PlaceOrderUseCase(mockOrderRepo, mockInventory);
const command = {
customerId: 'customer-123',
items: [
{ productId: 'prod-1', quantity: 2, price: 10 }
]
};
await useCase.execute(command);
// Verificar orden de llamadas
expect(mockInventory.checkStock).toHaveBeenCalledBefore(
mockOrderRepo.save as jest.Mock
);
});
test('no debería crear order si no hay stock', async () => {
const mockInventory = {
checkStock: jest.fn().mockResolvedValue(false),
reserve: jest.fn()
};
const mockOrderRepo = {
save: jest.fn()
};
const useCase = new PlaceOrderUseCase(mockOrderRepo, mockInventory);
const command = {
customerId: 'customer-123',
items: [{ productId: 'prod-1', quantity: 2, price: 10 }]
};
await expect(useCase.execute(command)).rejects.toThrow('sin stock');
expect(mockOrderRepo.save).not.toHaveBeenCalled();
expect(mockInventory.reserve).not.toHaveBeenCalled();
});
});
4. Testing de Adaptadores
Testing de Repositorios (Integration Tests)
// infrastructure/adapters/secondary/repositories/postgres-user.repository.spec.ts
import { Pool } from 'pg';
import { PostgresUserRepository } from './postgres-user.repository';
import { User } from '@domain/user/user.entity';
import { Email } from '@domain/user/email.vo';
describe('PostgresUserRepository', () => {
let pool: Pool;
let repository: PostgresUserRepository;
beforeAll(async () => {
// Crear pool de test
pool = new Pool({
connectionString: process.env.TEST_DATABASE_URL
});
repository = new PostgresUserRepository(pool);
// Limpiar BD
await pool.query('DELETE FROM users');
});
afterAll(async () => {
await pool.end();
});
afterEach(async () => {
await pool.query('DELETE FROM users');
});
test('debería guardar y recuperar usuario', async () => {
const email = Email.create('[email protected]');
const user = User.create('123', email, 'John Doe');
await repository.save(user);
const found = await repository.findById('123');
expect(found).not.toBeNull();
expect(found!.userId).toBe('123');
expect(found!.userEmail).toBe('[email protected]');
});
test('debería retornar null si usuario no existe', async () => {
const found = await repository.findById('non-existent');
expect(found).toBeNull();
});
test('debería encontrar por email', async () => {
const email = Email.create('[email protected]');
const user = User.create('123', email, 'John Doe');
await repository.save(user);
const found = await repository.findByEmail('[email protected]');
expect(found).not.toBeNull();
expect(found!.userId).toBe('123');
});
});
Testing de Controllers (Integration Tests)
// infrastructure/adapters/primary/api/user.controller.spec.ts
import request from 'supertest';
import express from 'express';
import { UserController } from './user.controller';
import { CreateUserUseCase } from '@application/user/use-cases/create-user.usecase';
describe('UserController', () => {
let app: express.Application;
let mockCreateUserUseCase: jest.Mocked<CreateUserUseCase>;
beforeEach(() => {
mockCreateUserUseCase = {
execute: jest.fn()
} as any;
const controller = new UserController(mockCreateUserUseCase);
app = express();
app.use(express.json());
app.post('/users', (req, res) => controller.createUser(req, res));
});
test('POST /users debería crear usuario', async () => {
mockCreateUserUseCase.execute.mockResolvedValue({
userId: '123',
email: '[email protected]',
name: 'John Doe'
});
const response = await request(app)
.post('/users')
.send({
email: '[email protected]',
name: 'John Doe',
password: 'password123'
});
expect(response.status).toBe(201);
expect(response.body).toEqual({
userId: '123',
email: '[email protected]',
name: 'John Doe'
});
});
test('POST /users debería retornar 400 en error', async () => {
mockCreateUserUseCase.execute.mockRejectedValue(
new Error('Email ya registrado')
);
const response = await request(app)
.post('/users')
.send({
email: '[email protected]',
name: 'John Doe',
password: 'password123'
});
expect(response.status).toBe(400);
expect(response.body).toEqual({
error: 'Email ya registrado'
});
});
});
5. Testing End-to-End
Características
- ✅ Completos: Toda la aplicación
- ✅ Lentos: Con infraestructura real
- ✅ Costosos: Mantenimiento alto
Ejemplo: E2E Test
// tests/e2e/user-registration.e2e.spec.ts
import request from 'supertest';
import { Pool } from 'pg';
import { createApp } from '@infrastructure/server';
describe('User Registration E2E', () => {
let app: express.Application;
let pool: Pool;
beforeAll(async () => {
pool = new Pool({
connectionString: process.env.TEST_DATABASE_URL
});
app = createApp(pool);
});
afterAll(async () => {
await pool.end();
});
beforeEach(async () => {
await pool.query('DELETE FROM users');
});
test('debería registrar usuario completo', async () => {
// 1. Crear usuario
const createResponse = await request(app)
.post('/users')
.send({
email: '[email protected]',
name: 'John Doe',
password: 'password123'
});
expect(createResponse.status).toBe(201);
const userId = createResponse.body.userId;
// 2. Obtener usuario creado
const getResponse = await request(app).get(`/users/${userId}`);
expect(getResponse.status).toBe(200);
expect(getResponse.body).toMatchObject({
email: '[email protected]',
name: 'John Doe'
});
// 3. Verificar que no se puede crear con mismo email
const duplicateResponse = await request(app)
.post('/users')
.send({
email: '[email protected]',
name: 'Jane Doe',
password: 'password456'
});
expect(duplicateResponse.status).toBe(400);
});
});
6. Estrategias de Mocking
Manual Mocks
// Implementación in-memory para tests
class InMemoryUserRepository implements UserRepository {
private users: Map<string, User> = new Map();
async save(user: User): Promise<void> {
this.users.set(user.userId, user);
}
async findById(id: string): Promise<User | null> {
return this.users.get(id) || null;
}
async findByEmail(email: string): Promise<User | null> {
for (const user of this.users.values()) {
if (user.userEmail === email) {
return user;
}
}
return null;
}
// Métodos de utilidad para tests
clear(): void {
this.users.clear();
}
count(): number {
return this.users.size;
}
}
Usando Manual Mocks
describe('CreateUserUseCase with InMemory', () => {
let repository: InMemoryUserRepository;
let useCase: CreateUserUseCase;
beforeEach(() => {
repository = new InMemoryUserRepository();
const passwordHasher = {
hash: async (pwd: string) => `hashed_${pwd}`
};
const emailService = {
sendWelcomeEmail: jest.fn()
};
useCase = new CreateUserUseCase(repository, passwordHasher, emailService);
});
test('debería crear usuario en memoria', async () => {
const command = {
email: '[email protected]',
name: 'John Doe',
password: 'password123'
};
await useCase.execute(command);
expect(repository.count()).toBe(1);
const saved = await repository.findByEmail('[email protected]');
expect(saved).not.toBeNull();
});
});
Test Builders (Patrón Builder para Tests)
// tests/builders/user.builder.ts
class UserBuilder {
private id = '123';
private email = '[email protected]';
private name = 'John Doe';
private status: 'active' | 'suspended' = 'active';
withId(id: string): this {
this.id = id;
return this;
}
withEmail(email: string): this {
this.email = email;
return this;
}
withName(name: string): this {
this.name = name;
return this;
}
suspended(): this {
this.status = 'suspended';
return this;
}
build(): User {
const user = User.create(this.id, Email.create(this.email), this.name);
if (this.status === 'suspended') {
user.suspend('Test suspension');
}
return user;
}
}
// Uso en tests
test('ejemplo con builder', () => {
const user = new UserBuilder()
.withId('custom-id')
.withEmail('[email protected]')
.suspended()
.build();
expect(user.userStatus).toBe('suspended');
});
7. Configuración de Jest
jest.config.js
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src', '<rootDir>/tests'],
testMatch: ['**/*.spec.ts', '**/*.test.ts'],
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.spec.ts',
'!src/**/*.test.ts',
'!src/**/index.ts'
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
}
},
moduleNameMapper: {
'^@domain/(.*)$': '<rootDir>/src/domain/$1',
'^@application/(.*)$': '<rootDir>/src/application/$1',
'^@infrastructure/(.*)$': '<rootDir>/src/infrastructure/$1'
},
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts']
};
tests/setup.ts
// Extender matchers de Jest
expect.extend({
toBeValidEmail(received: string) {
const pass = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(received);
return {
pass,
message: () =>
pass
? `expected ${received} not to be valid email`
: `expected ${received} to be valid email`
};
}
});
// Configuración global
beforeAll(() => {
// Setup global
});
afterAll(() => {
// Cleanup global
});
8. Conclusión
En este capítulo aprendiste:
- Pirámide de Testing: 70% unit, 20% integration, 10% e2e
- Testing de Dominio: Rápido, puro, sin dependencias
- Testing de Casos de Uso: Con mocks de puertos secundarios
- Testing de Adaptadores: Integration tests con infraestructura
- E2E Tests: Flujos completos de usuario
- Mocking: Manual mocks, builders, jest mocks
Principios clave:
- ✅ Testear cada capa de forma independiente
- ✅ Mayoría de tests en dominio y casos de uso
- ✅ Mocks para puertos secundarios
- ✅ Integration tests para adaptadores
- ✅ Pocos E2E tests para flujos críticos
En el próximo capítulo veremos cómo refactorizar código legacy hacia arquitectura hexagonal.
Glosario del Capítulo
| Término (Inglés) | Término (Español) | Definición |
|---|---|---|
| Unit Test | Prueba Unitaria | Test de componente aislado |
| Integration Test | Prueba de Integración | Test con infraestructura real |
| E2E Test | Prueba End-to-End | Test de flujo completo |
| Mock | Simulación | Objeto falso que simula comportamiento |
| Stub | Fragmento | Implementación simple para tests |
| Spy | Espía | Mock que registra interacciones |
| Test Double | Doble de Prueba | Término genérico para mock/stub/spy |
| Test Builder | Constructor de Prueba | Patrón para crear objetos de test |
| Arrange-Act-Assert | Organizar-Actuar-Verificar | Estructura de un test |