Contract Testing für Microservices — Fehler finden, bevor sie Production erreichen
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_atincreatedAtum. 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:
- Identifiziere Deinen schmerzhaftesten Integrationspunkt. Welche Service-Grenze verursacht die meisten Production-Incidents?
- Füge einen Consumer-Contract-Test für die 2-3 kritischsten Endpoints hinzu.
- Füge Provider-Verifikation zur CI des Providers hinzu.
- Richte einen Pact Broker ein (das Docker-Image reicht für den Anfang).
- 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.