← Volver al listado de tecnologías

Capítulo 21: Testing de Sagas

Por: SiempreListo
sagatestingvitestjestintegration

Capítulo 21: Testing de Sagas

“Una saga sin tests es una bomba de tiempo”

Introduccion

Las sagas son criticas para la integridad del sistema - un bug puede causar estados inconsistentes, cobros duplicados o pedidos perdidos. Por eso, el testing de sagas debe ser exhaustivo y cubrir multiples niveles.

Este capitulo presenta una estrategia de testing en tres niveles:

Usaremos Vitest (compatible con Jest) como framework de testing, y Playwright para tests end-to-end.

Niveles de Testing

La piramide de testing muestra que debemos tener mas tests unitarios (rapidos, baratos) y menos E2E (lentos, complejos):

┌─────────────────────────────────────┐
│            E2E Tests                │  Flujo completo
├─────────────────────────────────────┤
│       Integration Tests             │  Múltiples servicios
├─────────────────────────────────────┤
│         Unit Tests                  │  Steps individuales
└─────────────────────────────────────┘

Unit Tests - Steps

Los tests unitarios de steps verifican que cada paso funcione correctamente de forma aislada. Usamos mocks para simular dependencias externas.

Conceptos clave:

Cada test verifica un escenario especifico: exito, compensacion, y edge cases.

// tests/steps/create-order.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CreateOrderStep } from '../../saga/steps';
import { SagaContext } from '../../saga/types';

describe('CreateOrderStep', () => {
  let orderService: any;
  let step: CreateOrderStep;

  beforeEach(() => {
    orderService = {
      create: vi.fn(),
      cancel: vi.fn()
    };
    step = new CreateOrderStep(orderService);
  });

  it('should create order and update context', async () => {
    const mockOrder = { id: 'order-123', status: 'pending' };
    orderService.create.mockResolvedValue(mockOrder);

    const context: SagaContext = {
      customerId: 'cust-1',
      items: [{ productId: 'p1', quantity: 2 }],
      total: 99.99
    };

    await step.execute(context);

    expect(orderService.create).toHaveBeenCalledWith({
      customerId: 'cust-1',
      items: context.items,
      total: 99.99
    });
    expect(context.orderId).toBe('order-123');
  });

  it('should compensate by cancelling order', async () => {
    const context: SagaContext = { orderId: 'order-123' };
    orderService.cancel.mockResolvedValue(undefined);

    await step.compensate(context);

    expect(orderService.cancel).toHaveBeenCalledWith('order-123');
  });

  it('should not compensate if no orderId', async () => {
    const context: SagaContext = {};

    await step.compensate(context);

    expect(orderService.cancel).not.toHaveBeenCalled();
  });
});

Unit Tests - Orchestrator

Los tests del orquestador verifican la logica de coordinacion:

La funcion helper createMockStep crea steps falsos con execute y compensate mockeados, permitiendo simular exitos y fallos segun el test.

// tests/saga/orchestrator.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SagaOrchestrator } from '../../saga/orchestrator';
import { SagaStep, SagaContext } from '../../saga/types';

describe('SagaOrchestrator', () => {
  const createMockStep = (name: string, shouldFail = false): SagaStep<SagaContext> => ({
    name,
    execute: vi.fn().mockImplementation(async () => {
      if (shouldFail) throw new Error(`${name} failed`);
    }),
    compensate: vi.fn()
  });

  it('should execute all steps in order', async () => {
    const steps = [
      createMockStep('step1'),
      createMockStep('step2'),
      createMockStep('step3')
    ];

    const orchestrator = new SagaOrchestrator('saga-1', steps);
    const result = await orchestrator.execute({});

    expect(result.status).toBe('completed');
    expect(steps[0].execute).toHaveBeenCalled();
    expect(steps[1].execute).toHaveBeenCalled();
    expect(steps[2].execute).toHaveBeenCalled();
  });

  it('should compensate on failure', async () => {
    const steps = [
      createMockStep('step1'),
      createMockStep('step2', true),
      createMockStep('step3')
    ];

    const orchestrator = new SagaOrchestrator('saga-1', steps);
    const result = await orchestrator.execute({});

    expect(result.status).toBe('failed');
    expect(steps[0].compensate).toHaveBeenCalled();
    expect(steps[1].compensate).not.toHaveBeenCalled();
    expect(steps[2].execute).not.toHaveBeenCalled();
  });

  it('should compensate in reverse order', async () => {
    const compensationOrder: string[] = [];
    const steps = [
      {
        name: 'step1',
        execute: vi.fn(),
        compensate: vi.fn().mockImplementation(() => compensationOrder.push('step1'))
      },
      {
        name: 'step2',
        execute: vi.fn(),
        compensate: vi.fn().mockImplementation(() => compensationOrder.push('step2'))
      },
      {
        name: 'step3',
        execute: vi.fn().mockRejectedValue(new Error('fail')),
        compensate: vi.fn()
      }
    ];

    const orchestrator = new SagaOrchestrator('saga-1', steps);
    await orchestrator.execute({});

    expect(compensationOrder).toEqual(['step2', 'step1']);
  });
});

Integration Tests

Los tests de integracion verifican que multiples componentes trabajen correctamente juntos, usando infraestructura real (base de datos, servicios).

Diferencias con unit tests:

La variable TEST_DATABASE_URL apunta a una base de datos de prueba separada.

// tests/integration/order-saga.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { Pool } from 'pg';
import { CreateOrderSaga } from '../../saga/create-order-saga';
import { SagaRepository } from '../../saga/repository';

describe('Order Saga Integration', () => {
  let pool: Pool;
  let repository: SagaRepository;

  beforeAll(async () => {
    pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
    repository = new SagaRepository(pool);
    await pool.query('TRUNCATE sagas, orders, inventory_reservations CASCADE');
  });

  afterAll(async () => {
    await pool.end();
  });

  it('should complete full order flow', async () => {
    const saga = new CreateOrderSaga(repository);

    const result = await saga.execute('saga-int-1', {
      customerId: 'cust-test',
      items: [{ productId: 'p1', quantity: 1 }],
      total: 50.00
    });

    expect(result.status).toBe('completed');

    // Verificar estado en DB
    const savedSaga = await repository.findById('saga-int-1');
    expect(savedSaga?.status).toBe('completed');
    expect(savedSaga?.completedSteps.length).toBe(4);
  });

  it('should rollback on payment failure', async () => {
    // Simular fallo de pago
    vi.spyOn(paymentService, 'process').mockRejectedValueOnce(new Error('Insufficient funds'));

    const saga = new CreateOrderSaga(repository);

    const result = await saga.execute('saga-int-2', {
      customerId: 'cust-test',
      items: [{ productId: 'p1', quantity: 1 }],
      total: 50.00
    });

    expect(result.status).toBe('failed');

    // Verificar que el stock fue liberado
    const reservation = await pool.query(
      'SELECT * FROM inventory_reservations WHERE saga_id = $1',
      ['saga-int-2']
    );
    expect(reservation.rows[0]?.status).toBe('released');
  });
});

Testing con Testcontainers

Testcontainers es una biblioteca que levanta contenedores Docker para tests, proporcionando infraestructura real sin configuracion manual.

Ventajas:

El timeout de 60000ms en beforeAll permite tiempo para que el contenedor inicie.

// tests/integration/saga-with-containers.test.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { PostgreSqlContainer, StartedPostgreSqlContainer } from '@testcontainers/postgresql';
import { Pool } from 'pg';

describe('Saga with Testcontainers', () => {
  let container: StartedPostgreSqlContainer;
  let pool: Pool;

  beforeAll(async () => {
    container = await new PostgreSqlContainer()
      .withDatabase('orderflow_test')
      .start();

    pool = new Pool({
      host: container.getHost(),
      port: container.getPort(),
      database: container.getDatabase(),
      user: container.getUsername(),
      password: container.getPassword()
    });

    // Run migrations
    await runMigrations(pool);
  }, 60000);

  afterAll(async () => {
    await pool.end();
    await container.stop();
  });

  it('should persist saga state', async () => {
    const repository = new SagaRepository(pool);

    await repository.create({
      id: 'saga-tc-1',
      type: 'CREATE_ORDER',
      status: 'running',
      currentStep: 0,
      context: {},
      completedSteps: [],
      createdAt: new Date(),
      updatedAt: new Date()
    });

    const saved = await repository.findById('saga-tc-1');
    expect(saved).not.toBeNull();
    expect(saved?.status).toBe('running');
  });
});

E2E Test con Playwright

Playwright es un framework de testing E2E que controla navegadores reales. Los tests E2E verifican el flujo completo desde la perspectiva del usuario.

Conceptos clave:

Los data-testid son atributos HTML que identifican elementos para tests sin depender de clases CSS.

// tests/e2e/order-flow.spec.ts
import { test, expect } from '@playwright/test';

test.describe('Order Flow E2E', () => {
  test('should complete order and show tracking', async ({ page }) => {
    await page.goto('/checkout');

    // Agregar items al carrito (asumiendo que ya hay items)
    await page.click('[data-testid="confirm-order"]');

    // Esperar tracking
    await expect(page.locator('[data-testid="saga-status"]')).toBeVisible();

    // Verificar pasos
    await expect(page.locator('text=Crear Pedido')).toBeVisible();

    // Esperar completado (con timeout)
    await expect(page.locator('[data-testid="saga-status"]'))
      .toHaveText('completed', { timeout: 30000 });

    // Verificar todos los pasos completados
    const steps = page.locator('[data-testid="step-status"]');
    await expect(steps).toHaveCount(5);
  });

  test('should show error on payment failure', async ({ page }) => {
    // Usar tarjeta de prueba que falla
    await page.goto('/checkout?test_fail_payment=true');
    await page.click('[data-testid="confirm-order"]');

    await expect(page.locator('[data-testid="saga-status"]'))
      .toHaveText('failed', { timeout: 30000 });

    await expect(page.locator('text=Pago rechazado')).toBeVisible();
  });
});

Resumen

Glosario

Mock

Definicion: Objeto simulado que reemplaza una dependencia real durante testing, permitiendo controlar su comportamiento y verificar interacciones.

Por que es importante: Permite probar codigo en aislamiento sin depender de servicios externos, bases de datos o APIs que podrian ser lentos o no disponibles.

Ejemplo practico: orderService.create = vi.fn().mockResolvedValue({ id: '123' }) simula el servicio de ordenes retornando siempre el mismo ID.


Piramide de Testing

Definicion: Modelo que sugiere tener muchos unit tests (base), menos integration tests (medio) y pocos E2E tests (cima), optimizando costo vs cobertura.

Por que es importante: Los unit tests son rapidos y baratos de mantener; los E2E son lentos y fragiles. La piramide balancea confianza con practicidad.

Ejemplo practico: 80% unit tests (ejecutan en 5 segundos), 15% integration (2 minutos), 5% E2E (10 minutos).


beforeEach / afterAll

Definicion: Hooks de testing que ejecutan codigo antes de cada test (beforeEach) o despues de todos los tests de una suite (afterAll).

Por que es importante: Permiten configurar el estado inicial (resetear mocks, limpiar DB) y limpiar recursos (cerrar conexiones) de forma consistente.

Ejemplo practico: beforeEach(() => vi.clearAllMocks()) resetea todos los mocks antes de cada test, evitando contaminacion entre tests.


Testcontainers

Definicion: Biblioteca que levanta contenedores Docker durante tests, proporcionando infraestructura real (DB, Redis, Kafka) efimera y aislada.

Por que es importante: Elimina la necesidad de mantener infraestructura de prueba, y garantiza que los tests funcionan igual en cualquier maquina.

Ejemplo practico: new PostgreSqlContainer().start() levanta PostgreSQL en Docker, retorna la URL de conexion, y lo destruye al terminar los tests.


Data-testid

Definicion: Atributo HTML (data-testid="submit-button") usado para identificar elementos en tests E2E de forma estable.

Por que es importante: No depende de clases CSS o texto que pueden cambiar, haciendo los tests mas robustos ante cambios de diseno.

Ejemplo practico: page.locator('[data-testid="saga-status"]') encuentra el elemento sin importar si su clase CSS cambio de status-badge a badge-status.


Playwright

Definicion: Framework de testing E2E de Microsoft que controla navegadores reales (Chrome, Firefox, Safari) para simular interacciones de usuario.

Por que es importante: Verifica que la aplicacion funciona correctamente desde la perspectiva del usuario, detectando problemas de integracion UI-backend.

Ejemplo practico: El test navega a checkout, hace clic en “Confirmar”, y verifica que despues de 30 segundos el estado muestra “completed”.


Assertion

Definicion: Verificacion en un test que compara un valor actual con el esperado, fallando el test si no coinciden.

Por que es importante: Son el nucleo de cualquier test - definen que comportamiento esperamos y detectan cuando algo cambia.

Ejemplo practico: expect(result.status).toBe('completed') verifica que la saga termino exitosamente. Si es ‘failed’, el test falla.


Test Fixture

Definicion: Datos o estado predefinido usado en tests para proporcionar un punto de partida conocido y consistente.

Por que es importante: Garantiza que los tests son reproducibles y no dependen de datos aleatorios o estados previos.

Ejemplo practico: const testOrder = { customerId: 'cust-test', items: [...], total: 50.00 } es un fixture usado en multiples tests.


← Capítulo 20: Frontend Tracking | Capítulo 22: Observabilidad →