Saltar al contenido

Cómo escribir integration tests resilientes que sí detectan errores

Patrones para integration tests fiables, rápidos y que detectan errores reales: configuración de bases de datos, pruebas de API y tests estables.

6 min de lectura
Pirámide de tests que destaca la capa de integration test entre las pruebas unitarias y las pruebas end-to-end

Las pruebas unitarias verifican lógica aislada. Las pruebas end-to-end verifican flujos de usuario completos. Los integration tests ocupan el término medio: verifican que los componentes funcionen bien en conjunto. Una función que pasa las pruebas unitarias puede seguir fallando al conectarse a una base de datos real, un cliente HTTP real o una cola de mensajes real. Los integration tests detectan justamente esos fallos.

El reto está en mantener los integration tests rápidos y deterministas. Los tests inestables erosionan la confianza más rápido que no tener tests en absoluto. Los siguientes patrones producen integration tests que se ejecutan de forma confiable tanto en CI como en las máquinas de cada desarrollador.

Configuración de la base de datos de pruebas

Todo integration test necesita un estado de base de datos predecible. Hay dos estrategias posibles: reiniciar la base de datos antes de cada test, o usar transacciones que se reviertan al finalizar cada uno.

tstypescript
// ❌ Shared mutable state — tests depend on execution order
describe('UserService', () => {
  it('creates a user', async () => {
    const user = await userService.create({ name: 'Alice' });
    expect(user.id).toBeDefined();
  });
 
  it('lists all users', async () => {
    // Fails if 'creates a user' didn't run first
    // Fails if another test added extra users
    const users = await userService.findAll();
    expect(users).toHaveLength(1);
  });
});
tstypescript
// ✅ Transaction rollback — each test starts with a clean slate
import { dataSource } from '../src/database';
 
let queryRunner: QueryRunner;
 
beforeEach(async () => {
  queryRunner = dataSource.createQueryRunner();
  await queryRunner.startTransaction();
  // Override the default connection to use this transaction
  jest.spyOn(dataSource, 'createQueryRunner').mockReturnValue(queryRunner);
});
 
afterEach(async () => {
  await queryRunner.rollbackTransaction();
  await queryRunner.release();
  jest.restoreAllMocks();
});
 
describe('UserService', () => {
  it('creates a user', async () => {
    const user = await userService.create({ name: 'Alice' });
    expect(user.id).toBeDefined();
    // Transaction rolls back — database unchanged for next test
  });
 
  it('lists all users after creation', async () => {
    await userService.create({ name: 'Bob' });
    const users = await userService.findAll();
    expect(users).toHaveLength(1); // Only Bob — Alice was rolled back
  });
});

Revertir la transacción es rápido porque evita truncar y volver a poblar las tablas. Cada test se ejecuta de forma aislada, sin necesidad de limpiar el sistema de archivos ni la red.

Ciclo de vida de la base de datos de pruebas

Los integration tests necesitan una base de datos real, no un sustituto en memoria. SQLite se comporta de forma distinta a PostgreSQL. Simular la base de datos con un mock elimina justo la integración que se quería poner a prueba.

tstypescript
// test/setup.ts — shared database lifecycle
import { DataSource } from 'typeorm';
import { execSync } from 'child_process';
 
let testDataSource: DataSource;
 
export async function setupTestDatabase(): Promise<DataSource> {
  // Use a dedicated test database
  const dbName = `test_${process.env.JEST_WORKER_ID || '1'}`;
 
  testDataSource = new DataSource({
    type: 'postgres',
    host: process.env.DB_HOST || 'localhost',
    port: 5432,
    username: 'test_user',
    password: 'test_password',
    database: dbName,
    entities: ['src/entities/**/*.ts'],
    synchronize: true,
    logging: false,
  });
 
  await testDataSource.initialize();
  return testDataSource;
}
 
export async function teardownTestDatabase(): Promise<void> {
  if (testDataSource?.isInitialized) {
    await testDataSource.destroy();
  }
}
 
export function getTestDataSource(): DataSource {
  return testDataSource;
}
ymlyaml
# docker-compose.test.yml — test database container
services:
  test-db:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: test_user
      POSTGRES_PASSWORD: test_password
      POSTGRES_DB: test_1
    ports:
      - "5433:5432"
    tmpfs:
      - /var/lib/postgresql/data  # RAM disk — fast and disposable

Usar tmpfs para el directorio de datos de la base de datos mantiene los tests rápidos: las escrituras van a memoria, no a disco. El contenedor de la base de datos es desechable y se reconstruye desde cero en cada ejecución de CI.

Integration tests de API

Probar endpoints HTTP requiere enviar solicitudes reales a un servidor en ejecución. Usa supertest o una librería similar para hacer esas solicitudes sin tener que gestionar un proceso de servidor aparte.

tstypescript
import request from 'supertest';
import { app } from '../src/app';
import { setupTestDatabase, teardownTestDatabase } from './setup';
 
beforeAll(async () => {
  await setupTestDatabase();
});
 
afterAll(async () => {
  await teardownTestDatabase();
});
 
describe('POST /api/users', () => {
  it('creates a user and returns 201', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: 'alice@example.com' })
      .expect(201);
 
    expect(response.body).toMatchObject({
      id: expect.any(String),
      name: 'Alice',
      email: 'alice@example.com',
    });
  });
 
  it('returns 400 for invalid email', async () => {
    const response = await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: 'not-an-email' })
      .expect(400);
 
    expect(response.body.errors).toContainEqual(
      expect.objectContaining({ field: 'email' })
    );
  });
 
  it('returns 409 for duplicate email', async () => {
    await request(app)
      .post('/api/users')
      .send({ name: 'Alice', email: 'alice@example.com' });
 
    await request(app)
      .post('/api/users')
      .send({ name: 'Bob', email: 'alice@example.com' })
      .expect(409);
  });
});

Probar el ciclo completo de solicitud y respuesta detecta errores de serialización, problemas de middleware y fallos de validación que las pruebas unitarias no ven. Un handler que funciona perfectamente de forma aislada puede fallar cuando el middleware de autenticación modifica el objeto de la solicitud.

Cómo evitar los tests inestables

Los tests inestables suelen tener tres causas principales: dependencias de tiempo, estado compartido y datos no deterministas. Cada una tiene una solución específica.

tstypescript
// ❌ Timing dependency — passes locally, fails in slow CI
it('processes the job within 100ms', async () => {
  await jobQueue.enqueue({ type: 'email', to: 'alice@example.com' });
  await new Promise((resolve) => setTimeout(resolve, 100));
  const job = await jobQueue.getLatestCompleted();
  expect(job.status).toBe('completed');
});
 
// ✅ Poll with timeout — works regardless of machine speed
it('processes the job', async () => {
  await jobQueue.enqueue({ type: 'email', to: 'alice@example.com' });
 
  const job = await waitFor(
    () => jobQueue.getLatestCompleted(),
    {
      timeout: 5000,
      interval: 100,
      predicate: (j) => j?.status === 'completed',
    }
  );
 
  expect(job.status).toBe('completed');
});
tstypescript
// Helper: poll until a condition is met or timeout
async function waitFor<T>(
  fn: () => Promise<T>,
  options: {
    timeout: number;
    interval: number;
    predicate: (result: T) => boolean;
  }
): Promise<T> {
  const start = Date.now();
 
  while (Date.now() - start < options.timeout) {
    const result = await fn();
    if (options.predicate(result)) {
      return result;
    }
    await new Promise((r) => setTimeout(r, options.interval));
  }
 
  throw new Error(`waitFor timed out after ${options.timeout}ms`);
}
tstypescript
// ❌ Non-deterministic data — test depends on current time
it('creates an event with correct date', async () => {
  const event = await eventService.create({ title: 'Standup' });
  expect(event.createdAt).toEqual(new Date()); // Milliseconds can differ
});
 
// ✅ Deterministic time — freeze or approximate
it('creates an event with correct date', async () => {
  jest.useFakeTimers();
  jest.setSystemTime(new Date('2021-03-15T10:00:00Z'));
 
  const event = await eventService.create({ title: 'Standup' });
  expect(event.createdAt).toEqual(new Date('2021-03-15T10:00:00Z'));
 
  jest.useRealTimers();
});

Cómo probar los límites con servicios externos

Cuando tu código llama a APIs externas (proveedores de pago, servicios de email, datos de terceros), usa contract testing o respuestas grabadas en lugar de golpear los servicios reales.

tstypescript
import nock from 'nock';
 
describe('PaymentService', () => {
  afterEach(() => {
    nock.cleanAll();
  });
 
  it('processes a successful payment', async () => {
    nock('https://api.stripe.test')
      .post('/v1/charges')
      .reply(200, {
        id: 'ch_test_123',
        status: 'succeeded',
        amount: 2000,
        currency: 'usd',
      });
 
    const result = await paymentService.charge({
      amount: 2000,
      currency: 'usd',
      source: 'tok_test',
    });
 
    expect(result.status).toBe('succeeded');
    expect(result.chargeId).toBe('ch_test_123');
  });
 
  it('handles payment failure gracefully', async () => {
    nock('https://api.stripe.test')
      .post('/v1/charges')
      .reply(402, {
        error: { type: 'card_error', message: 'Card declined' },
      });
 
    const result = await paymentService.charge({
      amount: 2000,
      currency: 'usd',
      source: 'tok_declined',
    });
 
    expect(result.status).toBe('failed');
    expect(result.error).toContain('Card declined');
  });
});

nock intercepta las solicitudes HTTP a nivel de red, así que el código bajo prueba sigue usando su cliente HTTP real. Esto detecta problemas que quedarían ocultos si simularas la capa de servicio directamente: errores al construir URLs, formato incorrecto de headers, fallos al parsear la respuesta.

Cómo organizar los archivos de test

Agrupa los integration tests según el límite del sistema que ejercitan, no según el archivo fuente que prueban.

tests/
  integration/
    api/
      users.test.ts        # POST/GET/PUT/DELETE /api/users
      orders.test.ts       # POST/GET /api/orders
      auth.test.ts         # Login, logout, token refresh
    database/
      user-repository.test.ts
      order-repository.test.ts
    external/
      payment-service.test.ts
      email-service.test.ts
    setup.ts               # Shared database lifecycle
    helpers.ts             # Test utilities (waitFor, factories)
  unit/
    services/
    utils/
jsonjson
// jest.config.js — separate configs for unit and integration tests
{
  "projects": [
    {
      "displayName": "unit",
      "testMatch": ["<rootDir>/tests/unit/**/*.test.ts"]
    },
    {
      "displayName": "integration",
      "testMatch": ["<rootDir>/tests/integration/**/*.test.ts"],
      "globalSetup": "<rootDir>/tests/integration/global-setup.ts",
      "globalTeardown": "<rootDir>/tests/integration/global-teardown.ts"
    }
  ]
}

Separar las pruebas unitarias de los integration tests te permite ejecutar jest --selectProjects unit para obtener feedback rápido durante el desarrollo, y correr la suite completa en CI.

Puntos clave

  1. Usa rollback de transacciones para aislar la base de datos — más rápido que truncar tablas, garantiza un estado limpio en cada test
  2. Ejecuta los tests contra una base de datos real, no contra SQLite — la integración que no pruebas es la que se rompe en producción
  3. Sustituye las esperas fijas por polling — un waitFor con timeout elimina la inestabilidad causada por los tiempos
  4. Usa nock para las APIs externas — intercepta a nivel HTTP y detecta errores reales de serialización y de URLs
  5. Congela el tiempo en tus tests — las fechas no deterministas son la segunda causa más común de inestabilidad
  6. Separa la configuración de las pruebas unitarias y los integration tests — feedback rápido en local y cobertura completa en CI
Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX