← Volver al listado de tecnologías

Capítulo 21: Testing de Sistemas Event Sourced

Por: SiempreListo
event-sourcingtestingvitestintegration

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:

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:

// 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:

  1. Crean registros correctamente ante eventos de creación
  2. Actualizan registros ante eventos de modificación
  3. 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:

// 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

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 →