Contract Testing für Microservices: Pact oder OpenAPI-Schemavalidierung

Consumer-driven Contracts mit Pact und providerseitige OpenAPI-Validierung lösen unterschiedliche Probleme. Wann welcher Ansatz passt, wie sie sich kombinieren lassen und ein CI-Setup, das Breaking Changes blockiert.

Integrationsfehler zwischen Diensten entstehen selten durch komplexe Logik. Sie entstehen durch kleine, unspektakuläre Abweichungen: ein Feld, das von accountId in account_id umbenannt wurde, ein Betrag, der früher eine Zahl war und jetzt ein String ist, ein 404, aus dem ein 200 mit leerem Body wurde. Die eigenen Tests jedes Dienstes bestehen, End-to-End-Tests sind zu langsam und zu instabil, um jedes Dienstepaar abzudecken, und die Abweichung gelangt in die Produktion.

Contract Testing prüft die Vereinbarung zwischen zwei Diensten isoliert und schnell genug, um bei jedem Commit zu laufen. Es gibt zwei Hauptansätze: Consumer-driven Contract Testing, meist mit Pact, und spezifikationsbasiertes Testen gegen ein OpenAPI-Dokument. Sie werden oft als austauschbar behandelt. Das sind sie nicht, und wer für die eigene Situation den falschen wählt, erhält entweder trügerische Sicherheit oder viel Wartungsaufwand.

Was ein Contract Test tatsächlich prüft

Ein Contract Test beantwortet eine Frage: Können dieser Consumer und dieser Provider miteinander kommunizieren? Er testet keine Geschäftslogik („Wird der Kontostand korrekt berechnet?“). Er ersetzt weder funktionale Tests des Providers noch End-to-End-Tests zentraler Journeys. Er prüft Form und Semantik der Schnittstelle: Pfade, Methoden, Statuscodes, Header und die Felder, auf die sich ein Consumer verlässt.

Der Nutzen liegt im Zeitpunkt. Ein Contract Test läuft in der eigenen Pipeline jedes Dienstes, in Sekunden und ohne den anderen Dienst zu deployen. Ein Breaking Change lässt den Pull Request fehlschlagen, der ihn eingeführt hat, und nicht erst ein Staging-Deployment drei Tage später.

Consumer-driven Contracts mit Pact

Bei Pact schreibt der Consumer den Contract als Nebenprodukt seiner Unit-Tests. Der Test beschreibt die Anfragen, die der Consumer sendet, und die Antworten, die er erwartet. Pact startet einen Mock-Provider, der genau diese Antworten liefert, und zeichnet die Interaktionen in einer JSON-Pact-Datei auf.

accounts.consumer.pact.test.jsJavaScript
// npm i -D @pact-foundation/pact      run: node --test   (Node 20+)
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const { PactV3, MatchersV3 } = require('@pact-foundation/pact');
const { like, integer, string, regex } = MatchersV3;

// The real client code under test (normally imported from src/).
class AccountsClient {
  constructor(baseUrl) { this.baseUrl = baseUrl; }
  async getAccount(id) {
    const res = await fetch(`${this.baseUrl}/v1/accounts/${id}`, {
      headers: { Accept: 'application/json' },
    });
    if (res.status === 404) return null;
    if (!res.ok) throw new Error(`HTTP ${res.status}`);
    return res.json();
  }
}

const provider = new PactV3({
  consumer: 'web-app',
  provider: 'accounts-api',
  dir: path.resolve(process.cwd(), 'pacts'),
});

describe('accounts-api contract', () => {
  it('returns an existing account', () => {
    provider
      .given('account 42 exists')
      .uponReceiving('a request for account 42')
      .withRequest({
        method: 'GET',
        path: '/v1/accounts/42',
        headers: { Accept: 'application/json' },
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': regex('application/json.*', 'application/json') },
        // Only the fields this consumer actually reads, matched by type.
        body: like({
          id: integer(42),
          currency: string('EUR'),
          balance: regex('^-?\\d+\\.\\d{2}$', '100.00'),
        }),
      });

    return provider.executeTest(async (mockServer) => {
      const account = await new AccountsClient(mockServer.url).getAccount(42);
      assert.equal(account.currency, 'EUR');
      assert.match(account.balance, /^\d+\.\d{2}$/);
    });
  });

  it('returns null for an unknown account', () => {
    provider
      .given('account 999 does not exist')
      .uponReceiving('a request for a missing account')
      .withRequest({ method: 'GET', path: '/v1/accounts/999', headers: { Accept: 'application/json' } })
      .willRespondWith({ status: 404 });

    return provider.executeTest(async (mockServer) => {
      assert.equal(await new AccountsClient(mockServer.url).getAccount(999), null);
    });
  });
});

Beachten Sie, was der Contract enthält: nur die drei Felder, die dieser Consumer liest, abgeglichen nach Typ und Format statt nach exaktem Wert. Fügt der Provider zwanzig neue Felder hinzu, berührt das diesen Contract nicht. Genau diese Eigenschaft macht Consumer-driven Contracts präzise. Der Provider erfährt genau, welche Teile seiner API von wem genutzt werden.

Die Pact-Datei wird anschließend in einem Pact Broker veröffentlicht (Open Source oder gehostet als PactFlow). Die Pipeline des Providers lädt die relevanten Pacts und spielt sie gegen eine echte, laufende Instanz des Providers ab:

verify-provider.jsJavaScript
// Runs in the provider's CI against a locally started instance of accounts-api.
const { Verifier } = require('@pact-foundation/pact');
const db = require('./test-helpers/db'); // your own seeding helpers

new Verifier({
  provider: 'accounts-api',
  providerBaseUrl: 'http://localhost:8080',
  pactBrokerUrl: process.env.PACT_BROKER_BASE_URL,
  pactBrokerToken: process.env.PACT_BROKER_TOKEN,
  providerVersion: process.env.GIT_SHA,
  providerVersionBranch: process.env.GIT_BRANCH,
  publishVerificationResult: process.env.CI === 'true',
  // Verify what is on consumers' main branches and what is actually deployed.
  consumerVersionSelectors: [{ mainBranch: true }, { deployedOrReleased: true }],
  enablePending: true,
  stateHandlers: {
    'account 42 exists': () => db.seedAccount({ id: 42, currency: 'EUR', balance: '100.00' }),
    'account 999 does not exist': () => db.deleteAccount(999),
  },
})
  .verifyProvider()
  .then(() => console.log('Pact verification complete'))
  .catch((err) => { console.error(err); process.exit(1); });

Drei Konzepte sorgen dafür, dass das in der Praxis funktioniert:

  • Provider States (given('account 42 exists')) erlauben es dem Provider, die Daten anzulegen, die jede Interaktion benötigt. Jeder State-Handler ist ein kleines Stück Test-Setup auf Providerseite.
  • Consumer Version Selectors legen fest, welche Pacts verifiziert werden. mainBranch plus deployedOrReleased ist die übliche Empfehlung: das, woran Consumer gerade bauen, und das, was tatsächlich läuft.
  • can-i-deploy fragt vor jedem Deployment die Matrix der verifizierten Versionen im Broker ab. Wurde die Version, die Sie deployen möchten, nicht gegen den Stand in der Zielumgebung verifiziert, stoppt die Pipeline.
broker.shShell
# Consumer pipeline: publish the generated pact, tagged with commit and branch
pact-broker publish ./pacts \
  --consumer-app-version "$GIT_SHA" \
  --branch "$GIT_BRANCH" \
  --broker-base-url "$PACT_BROKER_BASE_URL" \
  --broker-token "$PACT_BROKER_TOKEN"

# Any pipeline, before deploying: is this version compatible with what is in production?
pact-broker can-i-deploy \
  --pacticipant web-app --version "$GIT_SHA" \
  --to-environment production \
  --broker-base-url "$PACT_BROKER_BASE_URL" --broker-token "$PACT_BROKER_TOKEN"

# After a successful deployment: tell the broker what is now running where
pact-broker record-deployment \
  --pacticipant web-app --version "$GIT_SHA" --environment production \
  --broker-base-url "$PACT_BROKER_BASE_URL" --broker-token "$PACT_BROKER_TOKEN"

Spezifikationsbasiertes Testen mit OpenAPI

Der andere Ansatz behandelt das OpenAPI-Dokument als Contract. Es gibt keinen Contract pro Consumer. Stattdessen gilt:

  • Der Provider wird auf Konformität mit der Spezifikation getestet. Werkzeuge wie Schemathesis erzeugen Anfragen aus der Spezifikation (Property-based Testing) und prüfen, ob die Antworten den dokumentierten Schemas und Statuscodes entsprechen. Einfachere Setups validieren echte Antworten in bestehenden Integrationstests gegen die Spezifikation, etwa mit Validator-Middleware.
  • Consumer entwickeln gegen Mocks, die aus derselben Spezifikation erzeugt werden (zum Beispiel mit Prism), oder generieren daraus typisierte Clients.
  • Änderungen an der Spezifikation werden in Pull Requests mit einem Diff-Werkzeug wie oasdiff auf Abwärtskompatibilität geprüft. Es stuft Änderungen wie das Entfernen eines Feldes, das Hinzufügen eines Pflichtparameters oder das Einschränken eines Enums als Breaking Change ein.

Das funktioniert ohne Mitwirkung der Consumer, und genau deshalb ist es der Standard für öffentliche APIs und Partner-APIs. Sie können Ihre Kunden nicht bitten, Pact-Tests zu schreiben, aber Sie können zusichern, dass die veröffentlichte Spezifikation korrekt ist und sich kompatibel weiterentwickelt.

Der entscheidende Unterschied: worauf der Contract beruht

Pact-Contracts beruhen auf dem, was Consumer tatsächlich nutzen. OpenAPI-Validierung beruht auf dem, was der Provider nach eigener Aussage anbietet. Daraus ergeben sich unterschiedliche Fehlerbilder:

  • OpenAPI-Validierung kann Ihnen nicht sagen, ob das Entfernen eines Feldes jemanden beeinträchtigt. Die Spezifikation führt das Feld als optional, also ist das Entfernen aus den Antworten „kompatibel“, doch ein Consumer, der sich darauf verlässt, bricht trotzdem. Pact würde das melden, weil der Pact des Consumers das Feld enthält.
  • Pact kann Ihnen nicht sagen, ob Ihre API ihrer Dokumentation entspricht. Ruft kein Consumer mit Pact-Tests einen Endpunkt auf, ist dieser Endpunkt überhaupt nicht abgedeckt.
  • Beide hängen von Genauigkeit ab. Ein Pact-Test mit zu lockerem Matcher oder eine Spezifikation mit additionalProperties: true und ohne Pflichtfelder lässt alles durch und schützt vor nichts.

Eine nützliche Faustregel: Pact prüft, ob ein bestimmtes Dienstepaar miteinander kommunizieren kann. OpenAPI prüft, ob der Provider sein öffentliches Versprechen hält.

Wann welcher Ansatz passt

Wählen Sie Pact (consumer-driven), wenn:

  • beide Seiten innerhalb Ihrer Organisation liegen und Teams sich über einen Broker abstimmen können;
  • Sie viele interne Consumer haben und genau wissen möchten, wer was nutzt, bevor Sie etwas ändern oder entfernen;
  • Sie Dienste unabhängig deployen und can-i-deploy als Sicherheitsschranke statt einer gemeinsamen Staging-Umgebung nutzen möchten;
  • Interaktionen über Message Queues laufen: Pact unterstützt auch asynchrone Message-Contracts, wo OpenAPI nicht greift (AsyncAPI ist das Pendant auf Spezifikationsseite).

Wählen Sie OpenAPI-Validierung, wenn:

  • die API öffentlich ist oder von Partnern genutzt wird, die Sie nicht steuern;
  • die Spezifikation bereits die maßgebliche Quelle ist (Design-first, generierte SDKs, veröffentlichte Dokumentation);
  • Consumer-Teams nicht die Kapazität oder Bereitschaft haben, Pact-Tests zu pflegen. Ein Pact-Setup, bei dem die Hälfte der Consumer fehlt, vermittelt ein gefährliches Gefühl von Abdeckung.

Nutzen Sie beides, wenn Sie ein internes Service-Mesh und eine öffentliche API haben: Pact zwischen internen Diensten, OpenAPI-Konformität und Diff-Prüfungen an der öffentlichen Schnittstelle. Das bidirektionale Contract Testing von PactFlow ist ein Mittelweg. Consumer veröffentlichen Pacts (oder Mocks aus anderen Werkzeugen), der Provider veröffentlicht seine OpenAPI-Spezifikation samt Nachweis der Konformität, und der Broker vergleicht beides statisch. Das erfordert weniger Aufwand auf Consumerseite, ist aber nur so zuverlässig wie die Konformitätstests des Providers.

Ein CI-Setup, das Breaking Changes blockiert

Für einen Provider mit veröffentlichter OpenAPI-Spezifikation fangen zwei Prüfungen bei jedem Pull Request die meisten Probleme ab: ein Diff der Spezifikation gegen main und ein Konformitätslauf gegen die tatsächliche Implementierung.

api-contract.ymlYAML
# .github/workflows/api-contract.yml (provider repository)
name: api-contract
on: [pull_request]

jobs:
  openapi:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      # 1. Breaking-change detection: compare the PR's spec with main
      - name: Detect breaking changes
        run: |
          git show origin/main:openapi.yaml > base.yaml
          docker run --rm -v "$PWD:/specs" tufin/oasdiff \
            breaking /specs/base.yaml /specs/openapi.yaml --fail-on ERR

      # 2. Does the implementation actually conform to the spec?
      - name: Start API
        run: docker compose up -d --wait api

      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'

      - name: Property-based conformance tests
        run: |
          pip install schemathesis
          schemathesis run openapi.yaml --url http://localhost:8080 --checks all

Worauf es in dieser Pipeline ankommt:

  • Vergleichen Sie mit dem Basis-Branch, nicht mit dem vorherigen Commit. Ein Breaking Change, der auf mehrere Commits verteilt ist, bleibt ein Breaking Change.
  • Erlauben Sie beabsichtigte Brüche ausdrücklich. Manchmal müssen Sie die API brechen, etwa mit einer neuen Major-Version. Machen Sie das zu einer sichtbaren Entscheidung (ein Label, eine Versionserhöhung, eine Ausnahmedatei), statt die Pipeline zu ändern.
  • Führen Sie Konformitätstests gegen eine echte Instanz mit realistischen Daten aus. Schemathesis findet überraschend viele 500-Fehler durch Eingaben, die laut Spezifikation gültig sind, vom Code aber nicht behandelt werden. Diese lohnt es sich zu beheben, auch wenn es keine Contract-Probleme sind.
  • Die CLI-Optionen von Schemathesis haben sich zwischen Major-Versionen geändert (zum Beispiel wurde --base-url aus 3.x in 4.x zu --url). Fixieren Sie die Version in der CI.

Für Pact sieht die Pipeline so aus: Die CI des Consumers führt die Pact-Tests aus und veröffentlicht; der Broker löst per Webhook die Provider-Verifizierung aus, wenn sich ein Pact ändert; beide Seiten führen vor dem Deployment can-i-deploy und danach record-deployment aus. Aktivieren Sie Pending Pacts, damit eine neue Erwartung eines Consumers den Main-Build des Providers nicht bricht, bevor der Provider sie umsetzen konnte.

Eine API weiterentwickeln, ohne Consumer zu brechen

Contract Tests zeigen Ihnen, wann eine Änderung ein Breaking Change ist. Sie brauchen trotzdem einen Weg, notwendige Änderungen sicher vorzunehmen. Das Muster, das mit beiden Ansätzen funktioniert, heißt Expand and Contract:

  1. Expand: Fügen Sie das neue Feld, den neuen Endpunkt oder Parameter neben dem alten hinzu. Das ist eine kompatible Änderung, und sowohl oasdiff als auch bestehende Pacts bestehen.
  2. Migrieren: Die Consumer stellen auf das neue Feld um. Bei Pact zeigt der Broker, welche Consumer-Versionen noch das alte Feld nutzen. Bei einer öffentlichen API stützen Sie sich auf Nutzungslogs und einen veröffentlichten Deprecation-Hinweis. Die HTTP-Antwortheader Deprecation und Sunset (RFC 9745 und RFC 8594) ermöglichen es Clients, dies programmatisch zu erkennen.
  3. Contract: Entfernen Sie das alte Feld erst, wenn keine verifizierte Consumer-Version mehr davon abhängt. can-i-deploy macht aus diesem „erst, wenn“ eine automatisierte Prüfung statt einer Vermutung.

Das dauert länger als ein einzelner Breaking Change. Es ist aber der einzige Weg, eine Schnittstelle, die von unabhängig deployten Diensten genutzt wird, ohne koordiniertes Release zu ändern.

Häufige Fallstricke

  • Die Logik des Providers in Pact testen. Contracts, die exakte Kontostände oder lange Wertelisten prüfen, werden fragil und duplizieren funktionale Tests. Gleichen Sie nach Typ und Format ab und prüfen Sie nur, was der Consumer wirklich braucht.
  • Zu viele Provider States. Jeder State ist Setup-Code, den der Provider pflegen muss. Halten Sie sie wenige und wiederverwendbar.
  • Eine Spezifikation, die niemand validiert. Handgeschriebene OpenAPI-Dokumente driften ab. Prüft nichts in der CI die Implementierung gegen die Spezifikation, ist die Spezifikation Dokumentation, kein Contract.
  • Fehlerantworten ignorieren. Contracts decken oft nur 200-Antworten ab. Consumer brechen genauso, wenn aus einem 404 ein 400 wird oder sich die Struktur des Fehler-Bodys ändert.
  • record-deployment auslassen. Ohne diesen Schritt weiß der Broker nicht, was in Produktion läuft, und can-i-deploy beantwortet die falsche Frage.

Wo Sie anfangen sollten

Wenn Sie noch nichts haben, beginnen Sie auf Providerseite: Machen Sie die OpenAPI-Spezifikation korrekt, ergänzen Sie oasdiff in Pull Requests und führen Sie Schemathesis gegen Staging aus. Das schützt alle Consumer auf einmal und fördert meist schon im ersten Lauf echte Fehler zutage. Ergänzen Sie Pact für die internen Dienstepaare, bei denen Breaking Changes tatsächlich Vorfälle verursacht haben. Messen Sie den Erfolg an der Zahl der Integrationsfehler, die Staging erreichen, nicht an der Zahl geschriebener Contracts.

Möchten Sie einen zweiten Blick auf Ihr System?

Fordern Sie den kostenlosen 20-Minuten-Check an: kurze schriftliche Befunde, nicht-invasiv, unverbindlich.

Kostenloser 20-Min-Check →