Si usas mocks de base de datos en los tests, estás probando que tu capa de repository llama a los métodos correctos. No estás probando que los datos sobrevivan a una caída, que una restricción única realmente bloquee duplicados, o que una transaction se revierta cuando algo falla.

Esa distinción importa. Un save() mockeado devuelve lo que tú le digas que devuelva. Un save() real puede provocar un deadlock, truncar una cadena silenciosamente, o escribir en una replica que va tres segundos detrás del primario. Tu suite de tests estará verde mientras tus usuarios pierden datos.

Por qué los tests de base de datos con mocks dan una falsa confianza

La mayoría de los tests de persistencia se ven así:

// A test that verifies syntax, not physics
jest.mock('./db', () => ({
  query: jest.fn().mockResolvedValue({ rows: [{ id: 1 }] }),
}));

it('saves a user', async () => {
  const repo = new UserRepository();
  const user = await repo.create({ name: 'Ada' });
  expect(user.id).toBe(1);
});

Este test demuestra que tu SQL es sintácticamente válido y que tu mapper no lanza excepciones. No demuestra nada sobre la persistencia.

Los verdaderos peligros habitan en la brecha entre “la query se ejecutó” y “los datos son duraderos”. ¿Tu ORM hace flush del buffer de escritura antes de devolver el control? ¿Tu pool de conexiones reutiliza una transaction que ya se revirió? ¿ON CONFLICT DO NOTHING se traga un insert que esperabas que tuviera éxito?

No encontrarás esos bugs con un mock que devuelve { id: 1 } bajo demanda.

El test de ida y vuelta: el test honesto más simple

El test de persistencia más básico es también el más honesto. Escribe datos, léelos de vuelta, comprueba que coinciden.

import { createConnection } from './db';

it('persists and retrieves a user', async () => {
  const db = await createConnection();
  const repo = new UserRepository(db);

  await repo.create({ id: 'user-1', name: 'Ada', email: 'ada@example.com' });

  const found = await repo.findById('user-1');
  expect(found).toEqual({
    id: 'user-1',
    name: 'Ada',
    email: 'ada@example.com',
  });
});

Esto atrapa los fallos obvios: commits omitidos, mapeos de columnas incorrectos, bugs del serializador. También atrapa los sutiles. Si tu método create hashea el email antes de escribir pero findById no hashea antes de consultar, este test falla. Un test mockeado no lo haría.

Probar las restricciones donde viven

Las restricciones de base de datos son lógica. Merecen tests igual que cualquier otra branch de tu código.

it('enforces unique emails at the database level', async () => {
  const db = await createConnection();
  const repo = new UserRepository(db);

  await repo.create({ id: 'user-1', email: 'ada@example.com' });

  await expect(
    repo.create({ id: 'user-2', email: 'ada@example.com' })
  ).rejects.toThrow(/unique constraint/i);
});

Este test documenta una regla de negocio en el único lugar que realmente la aplica. Si alguien elimina más tarde el index único para “arreglar” una migration inestable, este test grita. Un mock permitiría alegremente que dos usuarios compartan un email porque el mock no tiene concepto de index.

El comportamiento de las transacciones no es teórico

Tu aplicación probablemente envuelve múltiples escrituras en una transaction. El test para esa transaction debería demostrar que un fallo parcial deja la base de datos consistente.

it('rolls back the entire transfer on failure', async () => {
  const db = await createConnection();
  const accounts = new AccountRepository(db);

  await accounts.create({ id: 'A', balance: 100 });
  await accounts.create({ id: 'B', balance: 50 });

  // Force a failure mid-transaction by violating a check constraint
  const transfer = db.transaction(async (trx) => {
    const acc = new AccountRepository(trx);
    await acc.debit('A', 30); // succeeds
    await acc.debit('B', 9999); // exceeds balance, throws
  });

  await expect(transfer).rejects.toThrow();

  const a = await accounts.findById('A');
  const b = await accounts.findById('B');
  expect(a.balance).toBe(100); // rollback preserved original state
  expect(b.balance).toBe(50);
});

Este es el test que te salva de la página a las 3 a.m. donde faltan fondos en las cuentas porque una API descendente hizo timeout y el débito se comprometió pero el crédito no. Los mocks no pueden modelar el aislamiento de transacciones. Solo una base de datos real puede hacerlo.

Aislamiento: la parte difícil

La objeción que todo el mundo plantea es la velocidad y la inestabilidad. Si cada test golpea PostgreSQL, tu suite se arrastrará, y los tests interferirán entre sí.

Hay tres respuestas prácticas, y probablemente uses las tres.

Bases de datos plantilla. PostgreSQL y la mayoría de bases de datos serias soportan CREATE DATABASE ... TEMPLATE. Inicializas una base de datos con tu esquema, luego la clonas por archivo de test en milisegundos.

// test-setup.ts
import { execSync } from 'child_process';

let templateCreated = false;

export async function createIsolatedDatabase() {
  if (!templateCreated) {
    execSync('createdb test_template');
    execSync('psql test_template -f schema.sql');
    templateCreated = true;
  }
  const dbName = `test_${process.pid}_${Date.now()}`;
  execSync(`createdb -T test_template ${dbName}`);
  return connect(dbName);
}

Cada archivo de test obtiene una base de datos fresca. No se necesita lógica de limpieza. Al final de la ejecución, elimina los clones.

Transacciones como límites de aislamiento. Si las bases de datos plantilla son demasiado pesadas, ejecuta cada test dentro de una transaction y reviértela al final. La mayoría de clientes de base de datos exponen esto explícitamente.

afterEach(async () => {
  await db.query('ROLLBACK');
});

it('inserts a row', async () => {
  await db.query('BEGIN');
  await repo.create({ id: 'x' });
  expect(await repo.findById('x')).toBeDefined();
  // rolled back by afterEach
});

El problema es que el código que usa transacciones por sí mismo puede entrar en conflicto con la transaction externa de rollback. Las transacciones anidadas mediante SAVEPOINT suelen resolver esto, pero no todos los drivers las manejan limpiamente.

Testcontainers para paridad en CI. Si tus tests se ejecutan contra SQLite localmente y PostgreSQL en producción, estás probando una base de datos diferente. Usa Testcontainers para levantar la base de datos real en Docker en cada ejecución de CI.

import { PostgreSqlContainer } from '@testcontainers/postgresql';

const container = await new PostgreSqlContainer().start();
const db = await createConnection(container.getConnectionUri());

Añade 2-3 segundos a la ejecución de tests. Elimina toda la clase de bugs que solo se reproducen en Postgres 15 con standard_conforming_strings = on.

Qué mockear, y qué no

No estoy abogando por cero mocks. Los mocks son correctos en límites que no controlas: llamadas a la API de Stripe, subidas a S3, servidores SMTP. Esos son lentos, costosos y tienen rate limits.

Tu base de datos no es un límite externo en el mismo sentido. Es infraestructura que tú operas. Su comportamiento es determinista. Su esquema es código que tú escribiste. Mockearla significa probar contra una versión de fantasía de un sistema que realmente ejecutas.

La compensación es la velocidad. Un test con base de datos real tarda 20-50 ms. Uno mockeado tarda 2-5 ms. Si tienes diez mil tests de persistencia, esa diferencia importa. La mayoría de equipos no tienen diez mil tests de persistencia. Tienen cien, y la honestidad vale la espera.

Los tests de desviación de esquema atrapan los bugs que las revisiones de código omiten

Hay un test más que es vergonzosamente efectivo y casi nadie lo escribe. Después de que tus migraciones se ejecuten, comprueba que tus tipos de TypeScript siguen coincidiendo con el esquema.

import { sql } from './db';

it('types match the database schema', async () => {
  const columns = await sql`
    SELECT column_name, data_type, is_nullable
    FROM information_schema.columns
    WHERE table_name = 'users'
  `;

  const userColumns = new Map(columns.map((c) => [c.column_name, c]));

  // If someone adds a NOT NULL column without updating the type, this fails
  expect(userColumns.get('email').is_nullable).toBe('NO');
  expect(userColumns.get('created_at').data_type).toBe('timestamp with time zone');
});

Este es el test que atrapa la migration que añade un NOT NULL por defecto a una columna que tu TypeScript sigue considerando opcional. Es tedioso de mantener a mano, por eso existen herramientas como kysely-codegen o drizzle-kit generate. Pero incluso una pequeña versión manual te salvará de un incident en producción.

Preguntas frecuentes

¿Debería eliminar todos mis tests de base de datos mockeados?

No. Mantén los mocks para tests de lógica pura donde la base de datos es irrelevante, como probar que un servicio formatea correctamente una cadena de query. Reemplázalos con tests de base de datos real dondequiera que el comportamiento de persistencia sea el punto central.

¿Cómo manejo las migraciones en los tests?

Ejecuta las migraciones contra tu base de datos plantilla una vez, luego clónala. O ejecútalas por suite de tests si son rápidas. Nunca omitas las migraciones en CI y esperes que el esquema coincida.

¿Qué pasa con las replicas de lectura?

Si tu aplicación escribe en el primario y lee de la replica, tus tests deberían hacerlo también. Levanta dos conexiones en Testcontainers y configura el enrutamiento igual que en producción. Las lecturas obsoletas inducidas por latencia son bugs reales.

¿Esto aplica a bases de datos NoSQL?

Sí. El principio es el mismo. Un cliente de MongoDB mockeado no te dirá si tu index compuesto soporta la query que crees que sí. Uno real sí lo hará.

Ejecuta un test de ida y vuelta hoy

Elige el método de repository que tocas más a menudo. Elimina su mock. Escribe un test que cree una fila, la lea de vuelta, y compruebe que los campos coincidan. Mide el tiempo. Si tarda menos de 50 ms, escribe otro.

La mayoría de equipos descubre que los tests de base de datos real no son la razón por la que su suite es lenta. La razón suele ser levantar un navegador sin cabeza para un test unitario, o llamar a una API de pagos real en CI, o no paralelizar los archivos de test. Los tests de base de datos son honestos, lo suficientemente rápidos, y atrapan los bugs que te cuestan el sueño.