← Volver al listado de tecnologías

Capítulo 8: Testing en Arquitectura Hexagonal

Por: Alfred Pennyworth
arquitectura-hexagonaltypescripttestingjesttddmocks

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

2. Testing del Dominio

Características

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

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

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:

  1. Pirámide de Testing: 70% unit, 20% integration, 10% e2e
  2. Testing de Dominio: Rápido, puro, sin dependencias
  3. Testing de Casos de Uso: Con mocks de puertos secundarios
  4. Testing de Adaptadores: Integration tests con infraestructura
  5. E2E Tests: Flujos completos de usuario
  6. Mocking: Manual mocks, builders, jest mocks

Principios clave:

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 TestPrueba UnitariaTest de componente aislado
Integration TestPrueba de IntegraciónTest con infraestructura real
E2E TestPrueba End-to-EndTest de flujo completo
MockSimulaciónObjeto falso que simula comportamiento
StubFragmentoImplementación simple para tests
SpyEspíaMock que registra interacciones
Test DoubleDoble de PruebaTérmino genérico para mock/stub/spy
Test BuilderConstructor de PruebaPatrón para crear objetos de test
Arrange-Act-AssertOrganizar-Actuar-VerificarEstructura de un test

Referencias