Wenn du deine Datenbank in Tests mockst, testest du, dass dein Repository-Layer die richtigen Methoden aufruft. Du testest nicht, ob Daten einen Crash überleben, ob ein Unique Constraint tatsächlich Duplikate blockiert, oder ob eine Transaction bei einem Fehler rollt back.

Dieser Unterschied ist wichtig. Ein gemocktes save() gibt zurück, was immer du ihm vorgibst. Ein echtes save() kann deadlochen, einen String stillschweigend truncaten, oder auf eine Replica schreiben, die drei Sekunden hinter dem Primary hängt. Deine Test-Suite ist grün, während deine User Daten verlieren.

Warum gemockte Datenbank-Tests falsche Sicherheit geben

Die meisten Persistence-Tests sehen so aus:

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

Dieser Test beweist, dass dein SQL syntaktisch korrekt ist und dein Mapper nicht throwt. Er beweist nichts über Persistence.

Die echten Gefahren liegen in der Lücke zwischen „die Query lief“ und „die Daten sind durable“. Flusht dein ORM den Write-Buffer vor dem Return? Reused dein Connection Pool eine Transaction, die bereits gerollt back wurde? Schluckt ON CONFLICT DO NOTHING einen Insert, von dem du erwartet hast, dass er erfolgreich ist?

Diese Bugs findest du nicht mit einem Mock, der auf Befehl { id: 1 } zurückgibt.

Der Round-Trip-Test: der einfachste ehrliche Test

Der grundlegendste Persistence-Test ist auch der ehrlichste. Schreibe Daten, lies sie zurück, asserte, dass sie übereinstimmen.

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

Das fängt die offensichtlichen Fehler: fehlende Commits, falsche Column-Mappings, Serializer-Bugs. Es fängt auch die subtilen. Wenn deine create-Methode die E-Mail vor dem Schreiben hasht, findById aber nicht vor der Query hasht, failt dieser Test. Ein gemockter Test würde das nicht.

Constraints dort testen, wo sie leben

Datenbank-Constraints sind Logik. Sie verdienen Tests genau wie jeder andere Branch in deinem Code.

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

Dieser Test dokumentiert eine Business-Rule an der einzigen Stelle, die sie tatsächlich enforced. Wenn jemand später den Unique Index entfernt, um eine flaky Migration zu „fixen“, schreit dieser Test. Ein Mock würde problemlos zulassen, dass zwei User eine E-Mail teilen, denn der Mock hat kein Konzept eines Index.

Transaction-Verhalten ist nicht theoretisch

Deine Anwendung wrappt wahrscheinlich mehrere Writes in eine Transaction. Der Test für diese Transaction sollte beweisen, dass ein partieller Failure die Datenbank konsistent lässt.

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

Das ist der Test, der dich vor dem 3-Uhr-morgens-Page rettet, bei dem Accounts Geld fehlt, weil eine Downstream-API timed out hat und der Debit committed, der Credit aber nicht. Mocks können Transaction Isolation nicht modellieren. Nur eine echte Datenbank kann das.

Isolation: der schwierige Teil

Der Einwand, den jeder erhebt, ist Speed und Flakiness. Wenn jeder Test PostgreSQL trifft, wird deine Suite crawlen, und Tests werden sich gegenseitig stören.

Es gibt drei praktische Antworten, und du wirst wahrscheinlich alle drei nutzen.

Template-Datenbanken. PostgreSQL und die meisten ernsthaften Datenbanken unterstützen CREATE DATABASE ... TEMPLATE. Du initialisierst eine Datenbank mit deinem Schema, klonstrst sie dann pro Test-Datei in Millisekunden.

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

Jede Test-Datei bekommt eine frische Datenbank. Keine Cleanup-Logik nötig. Am Ende des Runs droppst du die Klone.

Transactions als Isolation-Boundaries. Wenn Template-Datenbanken zu heavy sind, führe jeden Test innerhalb einer Transaction aus und rolle sie am Ende back. Die meisten Datenbank-Clients exposen das explizit.

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

Der Haken ist, dass Code, der selbst Transactions nutzt, mit der outer Rollback-Transaction kollidieren kann. Nested Transactions via SAVEPOINT lösen das normalerweise, aber nicht jeder Driver handhabt sie sauber.

Testcontainers für CI-Parity. Wenn deine Tests lokal gegen SQLite laufen und in Production gegen PostgreSQL, testest du eine andere Datenbank. Nutze Testcontainers, um die echte Datenbank in Docker für jeden CI-Run zu spinnen.

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

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

Es fügt dem Test-Run 2–3 Sekunden hinzu. Es eliminiert die gesamte Klasse von Bugs, die sich nur auf Postgres 15 mit standard_conforming_strings = on reproduzieren.

Was man mocken sollte, und was nicht

Ich argumentiere nicht für zero mocks. Mocks sind korrekt an Boundaries, die du nicht kontrollierst: Stripe-API-Calls, S3-Uploads, SMTP-Server. Die sind langsam, teuer und rate-limited.

Deine Datenbank ist in demselben Sinne keine externe Boundary. Sie ist Infrastructure, die du betreibst. Ihr Verhalten ist deterministisch. Ihr Schema ist Code, den du geschrieben hast. Sie zu mocken bedeutet, gegen eine Fantasie-Version eines Systems zu testen, das du tatsächlich betreibst.

Der Trade-off ist Speed. Ein echter Datenbank-Test dauert 20–50 ms. Ein gemockter dauert 2–5 ms. Wenn du zehntausend Persistence-Tests hast, ist dieser Unterschied wichtig. Die meisten Teams haben nicht zehntausend Persistence-Tests. Sie haben hundert, und die Ehrlichkeit ist das Warten wert.

Schema-Drift-Tests fangen die Bugs, die Code Reviews verpassen

Es gibt noch einen Test, der embarrassingly effektiv ist und den fast niemand schreibt. Nachdem deine Migrations gelaufen sind, asserte, dass deine TypeScript-Typen immer noch zum Schema passen.

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

Das ist der Test, der die Migration erwischt, die einer Column, die TypeScript immer noch für optional hält, einen NOT NULL-Default hinzufügt. Es ist tedious, das per Hand zu maintainen, deshalb existieren Tools wie kysely-codegen oder drizzle-kit generate. Aber selbst eine kleine manuelle Version wird dir einen Production-Incident ersparen.

FAQ

Sollte ich alle meine gemockten Datenbank-Tests löschen?

Nein. Behalte Mocks für reine Logik-Tests, bei denen die Datenbank irrelevant ist, wie z. B. zu testen, dass ein Service einen Query-String korrekt formatiert. Ersetze sie durch echte Datenbank-Tests überall dort, wo Persistence-Verhalten der Punkt ist.

Wie handle ich Migrations in Tests?

Führe Migrations einmal gegen deine Template-Datenbank aus, klone dann. Oder führe sie pro Test-Suite aus, wenn sie schnell sind. Skippe niemals Migrations in CI und hoffe, dass das Schema passt.

Was ist mit Read Replicas?

Wenn deine App auf den Primary schreibt und von der Replica liest, sollten deine Tests das auch. Spinne zwei Connections in Testcontainers hoch und konfiguriere das Routing genau so wie Production. Lag-induced stale reads sind echte Bugs.

Gilt das auch für NoSQL-Datenbanken?

Ja. Das Prinzip ist dasselbe. Ein gemockter MongoDB-Client wird dir nicht sagen, ob dein Compound Index die Query unterstützt, von der du denkst, dass er sie tut. Ein echter schon.

Schreib noch heute einen Round-Trip-Test

Nimm die Repository-Methode, die du am häufigsten anfasst. Lösche ihren Mock. Schreibe einen Test, der eine Row erstellt, sie zurückliest und assertet, dass die Felder übereinstimmen. Time ihn. Wenn er unter 50 ms liegt, schreibe einen weiteren.

Die meisten Teams stellen fest, dass echte Datenbank-Tests nicht der Grund sind, warum ihre Suite langsam ist. Der Grund ist normalerweise, einen headless Browser für einen Unit-Test zu spinnen, oder eine echte Payment-API in CI aufzurufen, oder Test-Dateien nicht zu parallelisieren. Datenbank-Tests sind ehrlich, schnell genug, und sie fangen die Bugs, die dir den Schlaf rauben.