Zum Inhalt springen

Resiliente Integration Tests schreiben, die wirklich Bugs finden

Muster für Integration Tests, die zuverlässig und schnell sind und echte Bugs finden: Datenbank-Setup, API-Tests und stabile statt flakige Tests.

5 Min. Lesezeit
Testpyramide, die die Integration-Test-Ebene zwischen Unit-Tests und End-to-End-Tests hervorhebt

Unit-Tests prüfen isolierte Logik. End-to-End-Tests prüfen komplette User-Flows. Integration Tests liegen dazwischen – sie prüfen, ob die Komponenten korrekt zusammenspielen. Eine Funktion, die alle Unit-Tests besteht, kann trotzdem fehlschlagen, sobald sie mit einer echten Datenbank, einem echten HTTP-Client oder einer echten Message Queue verbunden wird. Genau solche Fehler decken Integration Tests auf.

Die Herausforderung besteht darin, Integration Tests schnell und deterministisch zu halten. Instabile Tests untergraben das Vertrauen schneller, als wenn es gar keine Tests gäbe. Die folgenden Muster liefern Integration Tests, die zuverlässig laufen – in der CI genauso wie auf den Rechnern der Entwickler.

Aufbau der Test-Datenbank

Jeder Integration Test braucht einen vorhersagbaren Datenbankzustand. Dafür gibt es zwei Strategien: die Datenbank vor jedem Test zurücksetzen, oder Transaktionen verwenden, die nach jedem Test zurückgerollt werden.

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

Das Zurückrollen einer Transaktion ist schnell, weil dabei weder ein Truncate noch ein erneutes Befüllen der Tabellen nötig ist. Jeder Test läuft isoliert, ganz ohne Aufräumen von Dateisystem oder Netzwerk.

Lebenszyklus der Test-Datenbank

Integration Tests brauchen eine echte Datenbank, keinen In-Memory-Ersatz. SQLite verhält sich anders als PostgreSQL. Wer die Datenbank mockt, eliminiert genau die Integration, die eigentlich getestet werden soll.

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

Mit tmpfs für das Datenverzeichnis der Datenbank bleiben Tests schnell – Schreibzugriffe landen im Speicher, nicht auf der Festplatte. Der Datenbank-Container ist ephemer – er wird bei jedem CI-Lauf komplett neu aufgesetzt.

API Integration Tests

Um HTTP-Endpunkte zu testen, müssen echte Requests an einen laufenden Server geschickt werden. Mit supertest oder einer ähnlichen Library lassen sich solche Requests senden, ohne einen separaten Serverprozess verwalten zu müssen.

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

Wer den kompletten Request-Response-Zyklus testet, findet Serialisierungsfehler, Middleware-Probleme und Validierungslogik, die Unit-Tests übersehen. Ein Handler, der isoliert einwandfrei funktioniert, kann scheitern, sobald eine Authentifizierungs-Middleware das Request-Objekt verändert.

Instabile Tests vermeiden

Instabile Tests haben meist drei Hauptursachen: zeitliche Abhängigkeiten, gemeinsam genutzten State und nicht-deterministische Daten. Für jede gibt es eine gezielte Lösung.

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

Grenzen zu externen Diensten testen

Wenn dein Code externe APIs aufruft (Zahlungsanbieter, E-Mail-Dienste, Daten von Drittanbietern), verwende Contract Testing oder aufgezeichnete Antworten, anstatt die echten Dienste anzusprechen.

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 fängt HTTP-Requests auf Netzwerkebene ab, sodass der getestete Code weiterhin seinen echten HTTP-Client benutzt. Dadurch fallen Probleme auf, die beim direkten Mocken der Service-Schicht verborgen geblieben wären – fehlerhafte URL-Konstruktion, falsches Header-Format, Fehler beim Parsen der Antwort.

Testdateien strukturieren

Gruppiere Integration Tests nach der Systemgrenze, die sie prüfen – nicht nach der Quelldatei, die sie testen.

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"
    }
  ]
}

Trennst du Unit- und Integration Tests, kannst du mit jest --selectProjects unit während der Entwicklung schnelles Feedback bekommen und trotzdem die komplette Suite in der CI laufen lassen.

Die wichtigsten Punkte

  1. Nutze Transaction Rollback zur Isolation der Datenbank — schneller als Truncate, garantiert einen sauberen Zustand pro Test
  2. Teste gegen eine echte Datenbank, nicht gegen SQLite — genau die Integration, die du nicht testest, bricht in Produktion
  3. Ersetze feste Wartezeiten durch Polling — ein waitFor mit Timeout beseitigt zeitabhängige Instabilität
  4. Nutze nock für externe APIs — es setzt auf HTTP-Ebene an und findet echte Serialisierungs- und URL-Bugs
  5. Friere die Zeit in Tests ein — nicht-deterministische Datumswerte sind die zweithäufigste Ursache für Instabilität
  6. Trenne die Konfiguration von Unit- und Integration Tests — schnelles Feedback lokal, volle Coverage in der CI
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX