Capítulo 21: Testing de Sistemas Event Sourced
Capítulo 21: Testing de Sistemas Event Sourced
“Given events, When command, Then new events”
Por Qué Event Sourcing Facilita el Testing
Event Sourcing tiene una ventaja natural para testing: los agregados son máquinas de estado deterministas. Dado el mismo conjunto de eventos, siempre obtenemos el mismo estado. Esto permite:
- Tests sin mocks: El agregado no tiene dependencias externas
- Escenarios claros: “Dado estos eventos, cuando ejecuto este comando, entonces estos eventos se emiten”
- Reproducibilidad: Podemos recrear cualquier bug reproduciendo la secuencia de eventos
Pirámide de Testing
╱╲
╱ ╲ E2E
╱────╲
╱ ╲ Integration
╱────────╲
╱ ╲ Unit
╱────────────╲
Testing de Agregados
Patrón Given-When-Then
El patrón Given-When-Then (también conocido como AAA: Arrange-Act-Assert) es perfecto para Event Sourcing:
- Given: Eventos que establecen el estado inicial del agregado
- When: Comando o acción que ejecutamos
- Then: Eventos que esperamos se emitan (o error esperado)
// tests/domain/order.test.ts
import { describe, it, expect } from 'vitest';
import { Order } from '@domain/aggregates/order';
import { OrderItem, Address } from '@domain/value-objects';
describe('Order Aggregate', () => {
const defaultItem: OrderItem = {
productId: 'prod-1',
productName: 'Widget',
sku: 'WDG-001',
quantity: 2,
unitPrice: { amount: 25, currency: 'USD' }
};
const defaultAddress: Address = {
street: '123 Main St',
city: 'Springfield',
state: 'IL',
zipCode: '62701',
country: 'US'
};
describe('Given: new order', () => {
describe('When: create with valid data', () => {
it('Then: emits OrderCreated event', () => {
const order = Order.create(
'customer-1',
'[email protected]',
[defaultItem],
defaultAddress
);
const events = order.getUncommittedEvents();
expect(events).toHaveLength(1);
expect(events[0].eventType).toBe('OrderCreated');
expect(order.status).toBe('draft');
});
it('Then: calculates totals correctly', () => {
const order = Order.create(
'customer-1',
'[email protected]',
[defaultItem],
defaultAddress
);
// 2 items * $25 = $50 + 8% tax = $54
expect(order.total.amount).toBe(54);
});
});
describe('When: create with empty items', () => {
it('Then: throws error', () => {
expect(() =>
Order.create('customer-1', '[email protected]', [], defaultAddress)
).toThrow('at least one item');
});
});
});
describe('Given: draft order', () => {
const createDraftOrder = () =>
Order.create('customer-1', '[email protected]', [defaultItem], defaultAddress);
describe('When: confirm', () => {
it('Then: emits OrderConfirmed event', () => {
const order = createDraftOrder();
order.clearUncommittedEvents();
order.confirm();
const events = order.getUncommittedEvents();
expect(events).toHaveLength(1);
expect(events[0].eventType).toBe('OrderConfirmed');
expect(order.status).toBe('confirmed');
});
});
describe('When: add item', () => {
it('Then: emits OrderItemAdded and updates total', () => {
const order = createDraftOrder();
order.clearUncommittedEvents();
const newItem: OrderItem = {
productId: 'prod-2',
productName: 'Gadget',
sku: 'GDG-001',
quantity: 1,
unitPrice: { amount: 50, currency: 'USD' }
};
order.addItem(newItem);
const events = order.getUncommittedEvents();
expect(events).toHaveLength(1);
expect(events[0].eventType).toBe('OrderItemAdded');
// (50 + 50) * 1.08 = 108
expect(order.total.amount).toBe(108);
});
});
});
describe('Given: confirmed order', () => {
const createConfirmedOrder = () => {
const order = Order.create(
'customer-1',
'[email protected]',
[defaultItem],
defaultAddress
);
order.confirm();
order.clearUncommittedEvents();
return order;
};
describe('When: try to add item', () => {
it('Then: throws error', () => {
const order = createConfirmedOrder();
expect(() => order.addItem(defaultItem))
.toThrow('invalid order status');
});
});
describe('When: receive payment', () => {
it('Then: emits PaymentReceived', () => {
const order = createConfirmedOrder();
order.receivePayment(
'pay-1',
{ amount: 54, currency: 'USD' },
'credit_card',
'txn-123'
);
const events = order.getUncommittedEvents();
expect(events[0].eventType).toBe('PaymentReceived');
expect(order.status).toBe('paid');
});
});
});
describe('Rehydration', () => {
it('should rebuild state from events', () => {
const original = Order.create(
'customer-1',
'[email protected]',
[defaultItem],
defaultAddress
);
original.confirm();
original.receivePayment(
'pay-1',
{ amount: 54, currency: 'USD' },
'credit_card',
'txn-123'
);
const events = original.getUncommittedEvents();
const rehydrated = Order.fromEvents(events);
expect(rehydrated.id).toBe(original.id);
expect(rehydrated.status).toBe('paid');
expect(rehydrated.total).toEqual(original.total);
});
});
});
Testing de Proyecciones
Las proyecciones transforman eventos en read models. Sus tests verifican que:
- Crean registros correctamente ante eventos de creación
- Actualizan registros ante eventos de modificación
- Son idempotentes: procesar el mismo evento dos veces no duplica datos
// tests/application/orders-projection.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { OrdersProjection } from '@application/projections/orders-projection';
import { InMemoryDatabase } from '../helpers/in-memory-database';
describe('OrdersProjection', () => {
let db: InMemoryDatabase;
let projection: OrdersProjection;
beforeEach(() => {
db = new InMemoryDatabase();
projection = new OrdersProjection(db);
});
it('should create order view on OrderCreated', async () => {
const event = {
globalPosition: 1n,
streamId: 'order-123',
streamPosition: 0,
eventType: 'OrderCreated',
data: {
customerId: 'cust-1',
items: [{ productId: 'p1', productName: 'Widget', quantity: 2, unitPrice: 25 }],
total: { amount: 54, currency: 'USD' }
},
metadata: {},
createdAt: new Date()
};
await projection.handle(event);
const view = db.get('orders_view', '123');
expect(view).toBeDefined();
expect(view.customerId).toBe('cust-1');
expect(view.status).toBe('draft');
expect(view.itemCount).toBe(1);
});
it('should update status on OrderConfirmed', async () => {
// Setup: crear orden
await projection.handle({
globalPosition: 1n,
streamId: 'order-123',
streamPosition: 0,
eventType: 'OrderCreated',
data: { customerId: 'cust-1', items: [], total: { amount: 0 } },
metadata: {},
createdAt: new Date()
});
// Act: confirmar
await projection.handle({
globalPosition: 2n,
streamId: 'order-123',
streamPosition: 1,
eventType: 'OrderConfirmed',
data: {},
metadata: {},
createdAt: new Date()
});
const view = db.get('orders_view', '123');
expect(view.status).toBe('confirmed');
});
it('should be idempotent', async () => {
const event = {
globalPosition: 1n,
streamId: 'order-123',
streamPosition: 0,
eventType: 'OrderCreated',
data: { customerId: 'cust-1', items: [], total: { amount: 0 } },
metadata: {},
createdAt: new Date()
};
// Procesar dos veces
await projection.handle(event);
await projection.handle(event);
// Solo una entrada
const count = db.count('orders_view');
expect(count).toBe(1);
});
});
Testing del Event Store
El Event Store es infraestructura crítica. Los tests deben verificar:
- Append: Escritura correcta de eventos
- Concurrency control: Detección de conflictos de versión
- Read: Lectura ordenada por stream y global
// tests/infrastructure/event-store.test.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { InMemoryEventStore } from '@infrastructure/event-store/in-memory-event-store';
import { ConcurrencyError } from '@infrastructure/event-store/types';
describe('EventStore', () => {
let store: InMemoryEventStore;
beforeEach(() => {
store = new InMemoryEventStore();
});
describe('append', () => {
it('should append to new stream', async () => {
const event = createTestEvent('TestEvent', 'stream-1', 0);
const result = await store.append('stream-1', [event], -1);
expect(result.eventsAppended).toBe(1);
expect(result.nextExpectedVersion).toBe(0);
});
it('should enforce optimistic concurrency', async () => {
const event1 = createTestEvent('E1', 'stream-1', 0);
await store.append('stream-1', [event1], -1);
const event2 = createTestEvent('E2', 'stream-1', 1);
// Versión incorrecta
await expect(store.append('stream-1', [event2], -1))
.rejects.toThrow(ConcurrencyError);
});
it('should allow concurrent appends to different streams', async () => {
const event1 = createTestEvent('E1', 'stream-1', 0);
const event2 = createTestEvent('E2', 'stream-2', 0);
await Promise.all([
store.append('stream-1', [event1], -1),
store.append('stream-2', [event2], -1)
]);
const s1 = await store.readStream('stream-1');
const s2 = await store.readStream('stream-2');
expect(s1).toHaveLength(1);
expect(s2).toHaveLength(1);
});
});
describe('readAll', () => {
it('should return events in global order', async () => {
await store.append('stream-1', [createTestEvent('A', 'stream-1', 0)], -1);
await store.append('stream-2', [createTestEvent('B', 'stream-2', 0)], -1);
await store.append('stream-1', [createTestEvent('C', 'stream-1', 1)], 0);
const all = await store.readAll();
expect(all.map(e => e.eventType)).toEqual(['A', 'B', 'C']);
});
});
});
function createTestEvent(type: string, streamId: string, version: number) {
return {
eventId: `${type}-${Date.now()}`,
eventType: type,
aggregateId: streamId,
aggregateType: 'Test',
version,
payload: {},
metadata: { correlationId: 'test', causationId: 'test', timestamp: new Date() }
};
}
Testing de Integración
Los tests de integración verifican que todos los componentes funcionan juntos correctamente. Usan bases de datos reales (no mocks) para detectar problemas de integración:
// tests/integration/order-flow.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { PostgresEventStore } from '@infrastructure/event-store/postgres-event-store';
import { OrderRepository } from '@infrastructure/repository/order-repository';
import { Order } from '@domain/aggregates/order';
import { setupTestDatabase, teardownTestDatabase } from '../helpers/database';
describe('Order Flow Integration', () => {
let eventStore: PostgresEventStore;
let repository: OrderRepository;
beforeAll(async () => {
const db = await setupTestDatabase();
eventStore = new PostgresEventStore(db);
repository = new OrderRepository(eventStore);
});
afterAll(async () => {
await teardownTestDatabase();
});
it('should persist and retrieve order', async () => {
// Create
const order = Order.create(
'cust-1',
'[email protected]',
[{ productId: 'p1', productName: 'Test', sku: 'T1', quantity: 1, unitPrice: { amount: 10, currency: 'USD' } }],
{ street: '123 St', city: 'City', state: 'ST', zipCode: '12345', country: 'US' }
);
await repository.save(order);
// Retrieve
const retrieved = await repository.getById(order.id);
expect(retrieved).not.toBeNull();
expect(retrieved!.id).toBe(order.id);
expect(retrieved!.status).toBe('draft');
});
it('should handle concurrent modifications', async () => {
const order = Order.create(
'cust-1',
'[email protected]',
[{ productId: 'p1', productName: 'Test', sku: 'T1', quantity: 1, unitPrice: { amount: 10, currency: 'USD' } }],
{ street: '123 St', city: 'City', state: 'ST', zipCode: '12345', country: 'US' }
);
await repository.save(order);
// Load two copies
const copy1 = await repository.getById(order.id);
const copy2 = await repository.getById(order.id);
// Modify both
copy1!.confirm();
copy2!.confirm();
// First save succeeds
await repository.save(copy1!);
// Second save should fail
await expect(repository.save(copy2!))
.rejects.toThrow('Concurrency');
});
});
Test Helpers
// tests/helpers/test-fixtures.ts
export function createOrderFixture(overrides = {}) {
return {
customerId: 'cust-test',
customerEmail: '[email protected]',
items: [{
productId: 'prod-test',
productName: 'Test Product',
sku: 'TEST-001',
quantity: 1,
unitPrice: { amount: 100, currency: 'USD' }
}],
shippingAddress: {
street: '123 Test St',
city: 'Test City',
state: 'TS',
zipCode: '12345',
country: 'US'
},
...overrides
};
}
export function createEventFixture(type: string, data = {}) {
return {
globalPosition: BigInt(Date.now()),
streamId: `test-${Date.now()}`,
streamPosition: 0,
eventType: type,
data,
metadata: {},
createdAt: new Date()
};
}
Resumen
- El patrón Given-When-Then es ideal para agregados
- Las proyecciones deben ser idempotentes
- Los tests de integración validan el flujo completo
- El control de concurrencia debe testearse explícitamente
- Los fixtures facilitan la creación de datos de prueba
Glosario
Given-When-Then
Definición: Patrón de estructura de tests donde Given establece precondiciones, When ejecuta la acción, y Then verifica resultados.
Por qué es importante: Hace los tests legibles y auto-documentados; cada test cuenta una historia clara del comportamiento esperado.
Ejemplo práctico: “Given: orden confirmada, When: recibir pago, Then: emite PaymentReceived y status cambia a paid”.
Pirámide de Testing
Definición: Modelo que sugiere muchos tests unitarios (base), menos tests de integración (medio), y pocos tests E2E (cima).
Por qué es importante: Los tests unitarios son rápidos y baratos; los E2E son lentos y frágiles. La pirámide optimiza el balance costo/beneficio.
Ejemplo práctico: 100 tests de agregados (ms cada uno), 20 tests de integración (segundos), 5 tests E2E (minutos).
Idempotencia en Proyecciones
Definición: Propiedad donde procesar el mismo evento múltiples veces produce el mismo resultado que procesarlo una vez.
Por qué es importante: Las proyecciones pueden reprocesar eventos (recovery, rebuild); sin idempotencia, tendríamos duplicados o corrupción.
Ejemplo práctico: INSERT ... ON CONFLICT DO UPDATE asegura que procesar OrderCreated dos veces no crea dos órdenes.
Test Fixture
Definición: Datos o estado predefinido usado como base para tests, evitando repetición de setup.
Por qué es importante: Reduce código duplicado y hace los tests más legibles al extraer datos comunes a funciones reutilizables.
Ejemplo práctico: createOrderFixture({total: 100}) crea una orden completa con valores por defecto que el test puede sobrescribir.
Concurrency Test
Definición: Test que verifica el comportamiento del sistema cuando múltiples operaciones ocurren simultáneamente.
Por qué es importante: Los bugs de concurrencia son difíciles de detectar en testing normal; tests específicos los exponen.
Ejemplo práctico: Cargar el mismo agregado en dos variables, modificar ambas, guardar ambas; la segunda debe fallar con ConcurrencyError.
Test Helper
Definición: Funciones auxiliares que simplifican tareas comunes en tests como crear datos, configurar mocks, o verificar resultados.
Por qué es importante: Reduce duplicación y hace los tests más expresivos al ocultar detalles de implementación.
Ejemplo práctico: createTestEvent('OrderCreated', {...}) encapsula la creación de eventos con metadata, timestamps, etc.
Integration Test
Definición: Test que verifica la interacción entre múltiples componentes del sistema, típicamente incluyendo infraestructura real.
Por qué es importante: Detecta problemas de integración que los unit tests con mocks no pueden encontrar.
Ejemplo práctico: Test que usa PostgreSQL real (via Testcontainers) para verificar que el repositorio persiste y recupera agregados correctamente.
Rehydration Test
Definición: Test que verifica que un agregado puede reconstruirse correctamente desde sus eventos.
Por qué es importante: Si la rehidratación falla, el sistema no puede cargar agregados existentes después de un reinicio.
Ejemplo práctico: Crear orden, confirmar, pagar; obtener eventos; crear nueva instancia con Order.fromEvents(events); verificar estado idéntico.
← Capítulo 20: Optimistic UI | Capítulo 22: Monitoreo y Observabilidad →