Se você mocka seu banco de dados nos testes, você está testando se sua camada de repository chama os métodos certos. Você não está testando se os dados sobrevivem a uma falha, se uma constraint única realmente bloqueia duplicatas, ou se uma transaction faz rollback quando algo falha.

Essa distinção importa. Um save() mockado retorna o que você mandar. Um save() real pode causar deadlock, truncar uma string silenciosamente, ou escrever em uma replica que está três segundos atrasada em relação ao primário. Sua test suite ficará verde enquanto seus usuários perdem dados.

Por que testes mockados de banco de dados dão falsa confiança

A maioria dos testes de persistência se parece com isso:

// 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);
});

Esse teste prova que seu SQL é sintaticamente válido e que seu mapper não lança exceção. Ele não prova nada sobre persistência.

Os riscos reais vivem no gap entre “a query rodou” e “os dados estão duráveis”. Seu ORM faz flush do write buffer antes de retornar? Seu connection pool reutiliza uma transaction que já fez rollback? O ON CONFLICT DO NOTHING engole um insert que você esperava que desse certo?

Você não vai encontrar esses bugs com um mock que retorna { id: 1 } sob demanda.

O teste de ida e volta: o teste honesto mais simples

O teste de persistência mais básico também é o mais honesto. Escreva dados, leia de volta, afirme que combinam.

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',
  });
});

Isso pega as falhas óbvias: commits faltando, mapeamentos de coluna errados, bugs de serializer. Também pega as sutis. Se seu método create faz hash do email antes de escrever, mas findById não faz hash antes de consultar, esse teste falha. Um teste mockado não falharia.

Testando constraints onde elas vivem

Constraints de banco de dados são lógica. Elas merecem testes assim como qualquer outro branch no seu 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);
});

Esse teste documenta uma regra de negócio no único lugar que realmente a executa. Se alguém remover o index único para “consertar” uma migration instável, esse teste grita. Um mock deixaria alegremente dois usuários compartilharem um email porque o mock não tem conceito de index.

O comportamento de transaction não é teórico

Sua aplicação provavelmente envolve múltiplas escritas em uma transaction. O teste para essa transaction deve provar que uma falha parcial deixa o banco de dados 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);
});

Esse é o teste que te salva da página das 3 da manhã onde contas estão sem dinheiro porque uma API downstream deu timeout e o débito foi committed mas o crédito não. Mocks não conseguem modelar isolamento de transaction. Só um banco de dados real consegue.

Isolamento: a parte difícil

A objeção que todo mundo levanta é velocidade e instabilidade. Se todo teste bate no PostgreSQL, sua suite vai engatinhar, e os testes vão interferir uns nos outros.

Existem três respostas práticas, e você provavelmente vai usar as três.

Bancos de dados template. PostgreSQL e a maioria dos bancos de dados sérios suportam CREATE DATABASE ... TEMPLATE. Você inicializa um banco de dados com seu schema, depois clona por arquivo de teste em milissegundos.

// 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 arquivo de teste recebe um banco de dados novo. Não precisa de lógica de limpeza. No final da execução, drop os clones.

transactions como fronteiras de isolamento. Se bancos de dados template são muito pesados, execute cada teste dentro de uma transaction e faça rollback no final. A maioria dos clientes de banco de dados expõe isso explicitamente.

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
});

O problema é que código que já usa transactions pode conflitar com a transaction externa de rollback. transactions aninhadas via SAVEPOINT geralmente resolvem isso, mas nem todo driver lida com elas de forma limpa.

Testcontainers para paridade de CI. Se seus testes rodam contra SQLite localmente e PostgreSQL em produção, você está testando um banco de dados diferente. Use Testcontainers para subir o banco de dados real em Docker para cada execução de CI.

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

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

Adiciona 2-3 segundos à execução dos testes. Elimina toda a classe de bugs que só reproduzem no Postgres 15 com standard_conforming_strings = on.

O que mockar, e o que não mockar

Não estou defendendo zero mocks. Mocks são corretos em fronteiras que você não controla: chamadas de API da Stripe, uploads para S3, servidores SMTP. Esses são lentos, caros e rate-limited.

Seu banco de dados não é uma fronteira externa no mesmo sentido. É infraestrutura que você opera. Seu comportamento é determinístico. Seu schema é código que você escreveu. Mocká-lo significa testar contra uma versão de fantasia de um sistema que você realmente executa.

A troca é velocidade. Um teste de banco de dados real leva 20-50ms. Um mockado leva 2-5ms. Se você tem dez mil testes de persistência, essa diferença importa. A maioria dos times não tem dez mil testes de persistência. Eles têm cem, e a honestidade vale a espera.

Testes de schema drift pegam os bugs que code reviews deixam passar

Existe mais um teste que é embaraçosamente eficaz e quase ninguém escreve. Depois que suas migrations rodam, afirme que seus tipos TypeScript ainda combinam com o schema.

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');
});

Esse é o teste que pega a migration adicionando um padrão NOT NULL a uma coluna que seu TypeScript ainda acha que é opcional. É tedioso manter manualmente, é por isso que ferramentas como kysely-codegen ou drizzle-kit generate existem. Mas mesmo uma pequena versão manual vai te salvar de um incident em produção.

Perguntas Frequentes

Devo deletar todos os meus testes mockados de banco de dados?

Não. Mantenha mocks para testes de lógica pura onde o banco de dados é irrelevante, como testar que um service formata uma query string corretamente. Substitua-os por testes de banco de dados real em qualquer lugar onde o comportamento de persistência é o ponto.

Como eu lido com migrations nos testes?

Execute migrations contra seu banco de dados template uma vez, depois clone. Ou execute por test suite se forem rápidas. Nunca pule migrations em CI e espere que o schema combine.

E quanto a read replicas?

Se sua app escreve no primário e lê da replica, seus testes também deveriam. Suba duas conexões no Testcontainers e configure o routing da mesma forma que a produção faz. Leituras stale causadas por lag são bugs reais.

Isso se aplica a bancos de dados NoSQL?

Sim. O princípio é o mesmo. Um cliente MongoDB mockado não vai te dizer se seu index composto suporta a query que você acha que suporta. Um real vai.

Execute um teste de ida e volta hoje

Escolha o método de repository que você mais toca. Delete o mock dele. Escreva um teste que cria uma row, lê de volta, e afirma que os campos combinam. Meça o tempo. Se levar menos de 50ms, escreva outro.

A maioria dos times descobre que testes de banco de dados real não são o motivo de sua suite ser lenta. O motivo geralmente é subir um headless browser para um unit test, ou chamar uma API de pagamento real em CI, ou não paralelizar arquivos de teste. Testes de banco de dados são honestos, rápidos o suficiente, e eles pegam os bugs que custam seu sono.