Capítulo 21: Testing de Sagas
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:
- Unit Tests: Prueban componentes aislados (steps individuales)
- Integration Tests: Prueban multiples componentes trabajando juntos
- E2E Tests: Prueban el flujo completo desde la UI hasta la base de datos
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:
- vi.fn(): Crea una funcion mock que registra llamadas y puede configurar valores de retorno
- mockResolvedValue: Configura el mock para retornar una promesa resuelta
- beforeEach: Ejecuta codigo antes de cada test, util para resetear mocks
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:
- Ejecucion secuencial de steps
- Compensacion cuando un step falla
- Orden correcto de compensaciones (LIFO)
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:
- Usan una base de datos de prueba real
- Limpian datos antes de cada suite (
TRUNCATE) - Verifican efectos secundarios en la DB
- Son mas lentos pero detectan problemas de integracion
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:
- Aislamiento: Cada test suite tiene su propia instancia de DB
- Reproducibilidad: El mismo test funciona en cualquier maquina
- Limpieza automatica: Los contenedores se destruyen al terminar
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:
- page.goto(): Navega a una URL
- page.click(): Hace clic en elementos
- page.locator(): Encuentra elementos en la pagina
- expect().toBeVisible(): Verifica que algo se muestre
- timeout: Tiempo maximo para esperar (las sagas pueden tardar)
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
- Unit tests para steps individuales
- Integration tests para flujos con DB real
- Testcontainers para ambientes aislados
- E2E tests verifican UI y backend juntos
- Verificar tanto exito como compensaciones
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 →