← Volver al listado de tecnologías

Testing de CQRS

Por: SiempreListo
cqrstestingvitesttypescriptgopython

Capítulo 22: Testing de CQRS

CQRS facilita el testing al separar claramente las responsabilidades. Cada componente (command handlers, query handlers, proyecciones) puede testearse de forma aislada con diferentes estrategias.

Estrategias de Testing para CQRS

La separación de CQRS permite testear el Write Side y Read Side independientemente.

Tests de Command Handlers (TypeScript)

Los tests de command handlers verifican que:

  1. El comando crea/modifica correctamente el aggregate
  2. El aggregate se persiste
  3. Los eventos se publican

vi es el objeto de Vitest para crear mocks y spies. vi.fn() crea una función mock que registra llamadas.

beforeEach se ejecuta antes de cada test, ideal para resetear mocks y crear instancias frescas.

// tests/command/create-order.handler.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CreateOrderHandler } from '../../src/command/orders/create-order.handler';
import { CreateOrderCommand } from '../../src/command/orders/create-order.command';

describe('CreateOrderHandler', () => {
  const mockRepo = { save: vi.fn() };
  const mockEventBus = { publish: vi.fn() };
  let handler: CreateOrderHandler;

  beforeEach(() => {
    vi.clearAllMocks();
    handler = new CreateOrderHandler(mockRepo, mockEventBus);
  });

  it('should create order and publish event', async () => {
    const command = new CreateOrderCommand('order-1', 'customer-1');

    await handler.handle(command);

    expect(mockRepo.save).toHaveBeenCalledWith(
      expect.objectContaining({
        id: 'order-1',
        customerId: 'customer-1',
        status: 'pending'
      })
    );
    expect(mockEventBus.publish).toHaveBeenCalledWith(
      expect.objectContaining({ type: 'OrderCreated' })
    );
  });

  it('should fail if repository throws', async () => {
    mockRepo.save.mockRejectedValue(new Error('DB error'));
    const command = new CreateOrderCommand('order-1', 'customer-1');

    await expect(handler.handle(command)).rejects.toThrow('DB error');
    expect(mockEventBus.publish).not.toHaveBeenCalled();
  });
});

Tests de Query Handlers

Los tests de query handlers verifican:

  1. Comportamiento con caché hit/miss
  2. Lectura correcta del read repository
  3. Actualización del caché después de miss

mockResolvedValue configura el valor que retornará una Promise cuando se resuelva.

// tests/query/get-order.handler.test.ts
import { describe, it, expect, vi } from 'vitest';
import { GetOrderHandler } from '../../src/query/orders/get-order.handler';
import { GetOrderQuery } from '../../src/query/orders/get-order.query';

describe('GetOrderHandler', () => {
  it('should return order from cache if available', async () => {
    const cachedOrder = { id: 'order-1', total: 100 };
    const mockCache = { get: vi.fn().mockResolvedValue(cachedOrder) };
    const mockReadRepo = { findById: vi.fn() };

    const handler = new GetOrderHandler(mockReadRepo, mockCache);
    const result = await handler.handle(new GetOrderQuery('order-1'));

    expect(result).toEqual(cachedOrder);
    expect(mockReadRepo.findById).not.toHaveBeenCalled();
  });

  it('should fetch from read repo if not cached', async () => {
    const order = { id: 'order-1', total: 100 };
    const mockCache = { get: vi.fn().mockResolvedValue(null), set: vi.fn() };
    const mockReadRepo = { findById: vi.fn().mockResolvedValue(order) };

    const handler = new GetOrderHandler(mockReadRepo, mockCache);
    const result = await handler.handle(new GetOrderQuery('order-1'));

    expect(result).toEqual(order);
    expect(mockCache.set).toHaveBeenCalledWith('order-1', order);
  });
});

Tests de Proyecciones

Las proyecciones transforman eventos en vistas de lectura. Los tests verifican que cada tipo de evento actualiza correctamente el read model.

expect.objectContaining permite verificar solo algunos campos de un objeto, ignorando los demás.

// tests/projections/order-summary.projection.test.ts
import { describe, it, expect, vi } from 'vitest';
import { OrderSummaryProjection } from '../../src/projections/order-summary.projection';

describe('OrderSummaryProjection', () => {
  const mockElastic = {
    index: vi.fn(),
    update: vi.fn()
  };

  it('should create document on OrderCreated', async () => {
    const projection = new OrderSummaryProjection(mockElastic);
    const event = {
      type: 'OrderCreated',
      aggregateId: 'order-1',
      data: { customerId: 'cust-1' },
      occurredAt: new Date()
    };

    await projection.handle(event);

    expect(mockElastic.index).toHaveBeenCalledWith({
      index: 'orders',
      id: 'order-1',
      document: expect.objectContaining({
        customerId: 'cust-1',
        status: 'pending'
      })
    });
  });

  it('should update total on ItemAdded', async () => {
    const projection = new OrderSummaryProjection(mockElastic);
    const event = {
      type: 'OrderItemAdded',
      aggregateId: 'order-1',
      data: { item: { price: 50, quantity: 2 } }
    };

    await projection.handle(event);

    expect(mockElastic.update).toHaveBeenCalled();
  });
});

Tests en Go

Go usa el paquete testing estándar. testify es una librería popular que agrega assertions más expresivas y mocks.

mock.Mock proporciona una estructura base para crear mocks. On().Return() configura el comportamiento esperado.

mock.MatchedBy permite verificar argumentos con funciones custom en lugar de valores exactos.

// internal/command/orders/create_order_test.go
package orders_test

import (
    "context"
    "testing"
    "orderflow/internal/command/orders"
    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

type MockRepo struct {
    mock.Mock
}

func (m *MockRepo) Save(ctx context.Context, order *order.Order) error {
    args := m.Called(ctx, order)
    return args.Error(0)
}

func TestCreateOrderHandler(t *testing.T) {
    repo := new(MockRepo)
    eventBus := new(MockEventBus)
    repo.On("Save", mock.Anything, mock.Anything).Return(nil)
    eventBus.On("Publish", mock.Anything, mock.Anything).Return(nil)

    handler := orders.NewCreateOrderHandler(repo, eventBus)
    cmd := orders.CreateOrderCommand{OrderID: "o-1", CustomerID: "c-1"}

    err := handler.Handle(context.Background(), cmd)

    assert.NoError(t, err)
    repo.AssertCalled(t, "Save", mock.Anything, mock.MatchedBy(func(o *order.Order) bool {
        return o.ID == "o-1" && o.CustomerID == "c-1"
    }))
}

Tests en Python

Python usa pytest para testing. @pytest.mark.asyncio permite testear funciones async.

AsyncMock es un mock especial para funciones async. assert_called_once() verifica que se llamó exactamente una vez.

call_args[0][0] accede al primer argumento de la primera llamada al mock.

# tests/command/test_create_order.py
import pytest
from unittest.mock import AsyncMock, MagicMock
from src.command.handlers.create_order import CreateOrderHandler, CreateOrderCommand

@pytest.mark.asyncio
async def test_create_order_publishes_event():
    repo = AsyncMock()
    event_bus = AsyncMock()
    handler = CreateOrderHandler(repo, event_bus)

    cmd = CreateOrderCommand(order_id="o-1", customer_id="c-1")
    await handler.handle(cmd)

    repo.save.assert_called_once()
    event_bus.publish.assert_called_once()

    saved_order = repo.save.call_args[0][0]
    assert saved_order.id == "o-1"
    assert saved_order.customer_id == "c-1"

@pytest.mark.asyncio
async def test_create_order_rolls_back_on_error():
    repo = AsyncMock()
    repo.save.side_effect = Exception("DB Error")
    event_bus = AsyncMock()
    handler = CreateOrderHandler(repo, event_bus)

    cmd = CreateOrderCommand(order_id="o-1", customer_id="c-1")

    with pytest.raises(Exception, match="DB Error"):
        await handler.handle(cmd)

    event_bus.publish.assert_not_called()

Tests de Integración

Los tests de integración verifican el flujo completo: comando -> evento -> proyección -> query.

waitForProjection espera a que la proyección procese los eventos antes de verificar la query. Esto es necesario porque la sincronización es eventualmente consistente.

// tests/integration/orders-flow.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { setupTestApp, teardownTestApp } from '../helpers/test-app';

describe('Orders Flow Integration', () => {
  let app: TestApp;

  beforeAll(async () => {
    app = await setupTestApp();
  });

  afterAll(async () => {
    await teardownTestApp(app);
  });

  it('should create order and retrieve via query', async () => {
    const createRes = await app.post('/orders', { customerId: 'c-1' });
    expect(createRes.status).toBe(202);

    await app.waitForProjection('orders');

    const getRes = await app.get(`/orders/${createRes.body.orderId}`);
    expect(getRes.status).toBe(200);
    expect(getRes.body.customerId).toBe('c-1');
  });
});

Resumen

Testing en CQRS:

Glosario

Unit Test

Definición: Test que verifica una unidad de código (función, clase, método) de forma aislada, usando mocks para las dependencias.

Por qué es importante: Permite detectar errores rápidamente, ejecutar miles de tests en segundos, y aislar la causa de fallos.

Ejemplo práctico: Testear CreateOrderHandler con mocks de repositorio y event bus, verificando que se llaman con los argumentos correctos.


Mock

Definición: Objeto que simula el comportamiento de una dependencia real, permitiendo controlar sus respuestas y verificar cómo se usa.

Por qué es importante: Aísla el código bajo test de sus dependencias. Permite testear casos difíciles (errores de DB, timeouts) sin infraestructura real.

Ejemplo práctico: mockRepo.save.mockRejectedValue(new Error('DB error')) simula un fallo de base de datos para verificar que el handler maneja el error.


Vitest

Definición: Framework de testing para JavaScript/TypeScript, compatible con la API de Jest pero significativamente más rápido, especialmente con Vite.

Por qué es importante: Proporciona una experiencia de testing moderna con hot reload, TypeScript nativo, y excelente integración con herramientas modernas.

Ejemplo práctico: describe, it, expect, vi.fn() son APIs de Vitest para estructurar tests, hacer assertions y crear mocks.


beforeEach / afterEach

Definición: Hooks que se ejecutan antes/después de cada test en un bloque describe, útiles para setup y cleanup.

Por qué es importante: Garantizan que cada test comience con estado limpio. Evitan que tests dependan unos de otros.

Ejemplo práctico: beforeEach(() => vi.clearAllMocks()) limpia los mocks antes de cada test, evitando que llamadas de tests anteriores afecten.


expect.objectContaining

Definición: Matcher de Vitest/Jest que verifica que un objeto contenga ciertas propiedades, ignorando las demás.

Por qué es importante: Permite assertions parciales cuando no importan todos los campos, o cuando algunos son dinámicos (timestamps, IDs).

Ejemplo práctico: expect.objectContaining({status: 'pending'}) pasa si el objeto tiene status ‘pending’, sin importar otros campos.


pytest

Definición: Framework de testing para Python que usa convenciones simples (funciones que empiezan con test_) y fixtures para setup.

Por qué es importante: Es el estándar de facto para testing en Python. Simple, extensible, y con excelente soporte para async.

Ejemplo práctico: @pytest.mark.asyncio async def test_create_order(): define un test async que pytest ejecutará correctamente.


AsyncMock

Definición: Tipo especial de mock en Python para funciones async. Retorna awaitable objects y permite verificar llamadas async.

Por qué es importante: Los mocks regulares no funcionan correctamente con await. AsyncMock maneja correctamente el protocolo async.

Ejemplo práctico: repo = AsyncMock() crea un mock donde await repo.save(order) funciona y se puede verificar con repo.save.assert_called_once().


Integration Test

Definición: Test que verifica la interacción entre múltiples componentes del sistema, posiblemente incluyendo bases de datos y servicios reales.

Por qué es importante: Detecta problemas de integración que los unit tests no pueden ver: incompatibilidades, configuración, timing.

Ejemplo práctico: Test que crea un pedido via HTTP, espera que la proyección procese, y verifica que la query retorna el pedido correcto.


testify

Definición: Librería de Go que proporciona assertions más expresivas y herramientas de mocking sobre el paquete testing estándar.

Por qué es importante: El paquete testing de Go es minimalista. Testify agrega assertions legibles y mocks estructurados.

Ejemplo práctico: assert.NoError(t, err) es más legible que if err != nil { t.Fatal(err) } y proporciona mejor output en fallos.


← Capítulo 21: Frontend React | Capítulo 23: Monitoreo →