← Alle Beiträge

Contract Testing für Microservices — Fehler finden, bevor sie Production erreichen

Matthias Bruns · · 6 Min. Lesezeit
testing microservices backend architecture ci-cd

Die Integrations-Test-Lüge

Du hast 95% Code Coverage. Deine Unit-Tests sind grün. Du deployst Deinen Service, und innerhalb von Minuten wirft der nachgelagerte Consumer 500er. Ein Response-Feld wurde von userId zu user_id umbenannt. Keiner hat’s gemerkt, weil niemand den Contract zwischen den Services getestet hat.

Das passiert ständig in Microservice-Architekturen. Jeder Service hat seine eigene Test-Suite, seine eigene CI-Pipeline, seinen eigenen Deploy-Rhythmus. Und der Raum zwischen den Services — der eigentliche API-Contract — ist dort, wo Dinge kaputtgehen.

Unit-Tests verifizieren Deine Logik. Integration-Tests verifizieren Deine Verdrahtung. Contract-Tests verifizieren Deine Versprechen.

Was Contract Testing wirklich ist

Ein Contract-Test validiert, dass zwei Services sich über Form und Verhalten ihrer Kommunikation einig sind. Der Consumer sagt: “Ich erwarte, dass GET /users/123 ein { id, name, email } zurückgibt.” Der Provider sagt: “Kann ich liefern.” Der Contract-Test verifiziert beide Seiten unabhängig voneinander.

Das ist keine neue Idee — sie existiert seit Ian Robinsons Arbeit zu Consumer-Driven Contracts Mitte der 2000er. Aber das Tooling ist deutlich gereift.

Die entscheidende Erkenntnis: Du brauchst nicht beide Services gleichzeitig laufen zu lassen, um Kompatibilität zu prüfen. Consumer-Tests generieren eine Contract-Datei. Provider-Tests verifizieren dagegen. Jeder läuft in seiner eigenen CI-Pipeline. Wenn eine Seite den Contract bricht, schlägt der Build vor dem Deployment fehl.

Consumer-Driven vs. Provider-Driven

Zwei Ansätze, unterschiedliche Tradeoffs:

Consumer-Driven Contracts (CDC) lassen den Consumer definieren, was er braucht. Der Provider muss alle Consumer-Contracts erfüllen. Das funktioniert gut, wenn Du mehrere Consumer mit unterschiedlichen Anforderungen hast — der Provider weiß genau, worauf sich jeder Consumer verlässt, und kann sich sicher weiterentwickeln.

Provider-Driven Contracts drehen das um: Der Provider veröffentlicht sein vollständiges API-Schema, und Consumer verifizieren, dass sie nur nutzen, was verfügbar ist. Das ist einfacher, wenn ein Provider viele Consumer bedient und auf Provider-Seite schnell vorankommen will.

In der Praxis starten die meisten Teams mit Consumer-Driven, weil es die Fehler fängt, die wirklich wehtun: Ein Provider ändert etwas, von dem ein Consumer abhängt.

Pact in der Praxis

Pact ist das am weitesten verbreitete Contract-Testing-Framework. Es unterstützt Consumer-Driven Contracts über Sprachen hinweg — JavaScript, Go, Java, Python, Rust und mehr.

So sieht der Ablauf aus:

Consumer-Seite

Der Consumer schreibt einen Test, der seine Erwartungen definiert:

// user-service.consumer.spec.ts
import { PactV4 } from '@pact-foundation/pact';

const provider = new PactV4({
  consumer: 'OrderService',
  provider: 'UserService',
});

describe('User API', () => {
  it('gibt User-Details zurück', async () => {
    await provider
      .addInteraction()
      .given('user 123 existiert')
      .uponReceiving('eine Anfrage für User 123')
      .withRequest('GET', '/users/123')
      .willRespondWith(200, (builder) => {
        builder.jsonBody({
          id: 123,
          name: 'Jane Doe',
          email: 'jane@example.com',
        });
      })
      .executeTest(async (mockServer) => {
        const user = await fetchUser(mockServer.url, 123);
        expect(user.name).toBe('Jane Doe');
      });
  });
});

Dieser Test generiert eine Pact-Datei — einen JSON-Contract, der die erwartete Interaktion beschreibt. Die Consumer-CI veröffentlicht diese an einen Pact Broker (oder PactFlow für die gehostete Version).

Provider-Seite

Der Provider führt eine Verifikation gegen alle Consumer-Pacts durch:

// user-service.provider.spec.ts
import { Verifier } from '@pact-foundation/pact';

describe('User API Provider-Verifikation', () => {
  it('erfüllt alle Consumer-Contracts', async () => {
    await new Verifier({
      providerBaseUrl: 'http://localhost:3000',
      pactBrokerUrl: 'https://your-broker.pactflow.io',
      provider: 'UserService',
      providerStatesSetupUrl: 'http://localhost:3000/test/setup',
    }).verifyProvider();
  });
});

Der Provider startet seinen echten Server (oder eine nahe Annäherung), und Pact spielt die erwarteten Interaktionen jedes Consumers dagegen ab. Wenn eine Interaktion fehlschlägt, bricht der Provider-Build.

Über Pact hinaus: Schema-basierte Contracts

Nicht jedes Team braucht Pacts volle Zeremonie. Wenn Deine Services über OpenAPI, gRPC oder GraphQL kommunizieren, hast Du bereits einen Schema-Contract.

OpenAPI + Spectral: Linte Deine OpenAPI-Specs auf Breaking Changes bei jedem PR. Tools wie openapi-diff können entfernte Felder, geänderte Typen oder eingeschränkte Enums automatisch erkennen.

gRPC + Protocol Buffers: Protobufs Wire-Format ist von Natur aus rückwärtskompatibel, wenn Du dem Proto3 Style Guide folgst. Nutze buf zur Erkennung von Breaking Changes in der CI.

GraphQL: Das Schema ist der Contract. Tools wie GraphQL Inspector erkennen Breaking Changes zwischen Schema-Versionen.

Der Tradeoff: Schema-basierte Ansätze fangen strukturelle Brüche, verpassen aber verhaltensbasierte Contracts. Sie sagen Dir nicht, dass das status-Feld jetzt "ACTIVE" statt "active" zurückgibt. Pact fängt beides.

Contract Tests in die CI einbinden

Contract-Tests gehören in Deine Pull-Request-Pipeline, nicht in einen Nightly-Job, den Du einmal pro Woche checkst. Hier ein praktisches Setup:

# .github/workflows/contract-tests.yml
name: Contract Tests
on: [pull_request]

jobs:
  consumer-contracts:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run test:contract
      - run: |
          npx pact-broker publish ./pacts \
            --consumer-app-version=${{ github.sha }} \
            --branch=${{ github.head_ref }}

  provider-verification:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run start:test &
      - run: npm run test:contract:verify

Die entscheidende Ergänzung: can-i-deploy. Bevor Du einen Service deployst, frag den Pact Broker, ob alle Contracts erfüllt sind:

npx pact-broker can-i-deploy \
  --pacticipant UserService \
  --version $(git rev-parse HEAD) \
  --to-environment production

Dieses einzelne Kommando verhindert das Deployment eines Providers, der seine Consumer brechen würde, oder eines Consumers, der von einer Provider-Änderung abhängt, die noch nicht ausgerollt ist.

Wann Contract Tests Dich retten

Reale Szenarien, in denen Contract-Tests fangen, was andere Tests übersehen:

  • Feld-Umbenennungen. Provider benennt created_at in createdAt um. Unit-Tests grün auf beiden Seiten. Contract-Test schlägt fehl.
  • Typ-Änderungen. Ein ID-Feld wechselt von Number zu String. Der JSON-Parser des Consumers konvertiert stillschweigend — bis er es nicht mehr tut.
  • Entfernte optionale Felder. Provider sendet ein Feld nicht mehr, das der Consumer als optional behandelt, aber tatsächlich für einen bestimmten Code-Pfad braucht.
  • Enum-Erweiterung. Provider fügt einen neuen Status-Wert hinzu. Das Switch-Statement des Consumers fällt in den Error-Case.
  • Paginierungs-Änderungen. Response-Wrapping ändert sich von { items: [...] } zu { data: [...], meta: {...} }. Jeder Consumer bricht.

Jedes davon besteht Unit-Tests. Jedes bricht Production. Jedes wird von einem Contract-Test gefangen.

Häufige Fehler

Zu viel testen. Contract-Tests verifizieren das Interface, nicht die Business-Logik. Prüfe nicht, dass die Response exakt 3 Items enthält — prüfe, dass sie ein Array von Objekten mit der erwarteten Form enthält.

Provider States nicht einrichten. Wenn Dein Consumer-Test “User 123 existiert” erwartet, braucht die Provider-Verifikation einen Setup-Endpoint, der diesen Zustand erzeugt. Ohne das bekommst Du flaky Tests, die vom Datenbankinhalt abhängen.

Contracts als Integration-Tests behandeln. Contract-Tests ersetzen die Notwendigkeit von End-to-End-Integrationstests zwischen bestimmten Service-Paaren. Sie ersetzen nicht die funktionalen Tests Deines Services.

Nur auf main laufen lassen. Consumer-Contracts sollten von Feature-Branches veröffentlicht werden. Sonst entdeckst Du Brüche erst nach dem Merge — genau dann, wenn es am schmerzhaftesten ist.

Klein anfangen

Du musst nicht am ersten Tag jede Service-Interaktion contract-testen. Starte mit:

  1. Identifiziere Deinen schmerzhaftesten Integrationspunkt. Welche Service-Grenze verursacht die meisten Production-Incidents?
  2. Füge einen Consumer-Contract-Test für die 2-3 kritischsten Endpoints hinzu.
  3. Füge Provider-Verifikation zur CI des Providers hinzu.
  4. Richte einen Pact Broker ein (das Docker-Image reicht für den Anfang).
  5. Füge can-i-deploy zu Deiner Deployment-Pipeline hinzu.

Ein Contract zwischen zwei Services. Das war’s. Erweitere von dort aus, basierend darauf, wo Brüche tatsächlich passieren.

Das Ziel ist nicht 100% Contract Coverage. Es ist, die Kategorie von Fehlern zu verhindern, die durch jede andere Test-Schicht rutschen — die, die erst auftauchen, wenn zwei Services in Production miteinander reden.

Das sind die teuren.

Lesebarkeit

Schriftgröße