Jeder API-Ausfall, der auf meinem Team in die Produktion geschafft hat, hat die CI bestanden. Jeder einzelne. Die Unit-Tests waren grün. Die integration tests waren bestanden. Das Deployment ging raus, und dann fingen die Slack-Nachrichten an.

Das Problem war nicht, dass wir nicht getestet haben. Wir haben das Falsche getestet. Die meisten CI-Pipelines prüfen, ob Code läuft. Sie prüfen nicht, ob der API-Contract zwischen Producer und Consumer noch intakt ist.

Warum Ihre bestehenden Tests API-Brüche nicht erkennen

Unit-Tests trainieren Ihre interne Logik. Sie rufen Ihren Handler mit gemockten dependencies auf und prüfen die Antwort. integration tests starten Ihren gesamten Stack und sprechen echte Endpunkte an.

Beide testen den Producer. Sie verifizieren, dass Ihre API sich korrekt verhält, wenn sie so aufgerufen wird, wie Sie sie aktuell aufrufen. Sie testen nicht, ob ein externer Consumer, der auf den Contract der vergangenen Woche aufgebaut ist, noch mit Ihnen kommunizieren kann.

Die subtilen Brüche sind die gefährlichen. Sie benennen ein Feld von user_id in userId um, weil Ihr Linter sich beschwert hat. Sie ändern eine 200-Antwort so, dass sie ein verschachteltes Objekt statt eines flachen zurückgibt. Sie machen einen Query-Parameter verpflichtend, der vorher optional war. Ihre eigenen Tests werden im selben PR aktualisiert, also ist alles bestanden. Aber jeder Client in der Produktion bricht zusammen.

Das ist der Unterschied zwischen dem Testen von Code und dem Testen von Contracts.

Was als Breaking API Change zählt

Ein Breaking Change ist jede Änderung, die einen korrekt implementierten Client zum Scheitern bringt. Es geht nicht um Bugs. Es geht um das Versprechen, das Ihre API gegeben hat.

Die häufigsten Brüche fallen in drei Kategorien:

  • Strukturelle Änderungen: Entfernen oder Umbenennen von Feldern, Typänderungen, Ändern der Verschachtelung
  • Verhaltensänderungen: Optionale Parameter verpflichtend machen, Ändern von Paginierungs-Defaults, Modifizieren von Fehlerantwort-Strukturen
  • Lebenszyklusänderungen: Endpunkte entfernen, URL-Pfade ändern, Versionen ohne Warnung als deprecated markieren

Einige davon sind offensichtlich. Andere sind nur offensichtlich, wenn man die API aus der Perspektive des Consumers betrachtet. Die meisten Teams tun das nicht.

Layer 1: Strukturelle Brüche mit OpenAPI-Diffing erkennen

OpenAPI-Spezifikationen beschreiben die Form Ihrer API. Wenn Sie Ihre Spezifikation als Contract behandeln, können Sie sie mit der vorherigen Version vergleichen und Breaking Changes vor dem Merge markieren.

Tools wie oasdiff vergleichen zwei OpenAPI-Dokumente und kategorisieren Änderungen als breaking, dangerous oder safe. Ein Breaking Change ist zum Beispiel das Entfernen eines Response-Feldes oder das Ändern eines Parameters von optional zu verpflichtend.

So sieht es in einem GitHub Actions Workflow aus:

# .github/workflows/api-contract.yml
name: API Contract Check

on:
  pull_request:
    paths:
      - 'openapi.yaml'

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

      - name: Download oasdiff
        run: |
          curl -L https://github.com/Tufin/oasdiff/releases/latest/download/oasdiff_linux_amd64.tar.gz | tar xz
          sudo mv oasdiff /usr/local/bin/

      - name: Check for breaking changes
        run: |
          oasdiff breaking \
            --base origin/main:openapi.yaml \
            --revision openapi.yaml \
            --fail-on WARN

Das lässt den Build fehlschlagen, wenn der PR einen strukturellen Breaking Change einführt. Es ist schnell, deterministisch und erkennt die Umbenennungen und Entfernungen, die integration tests übersehen.

Es gibt einen Haken. OpenAPI-Diffing sieht nur das Schema. Es kann nicht erkennen, ob Sie die Bedeutung eines Feldes geändert haben, während der Typ gleich geblieben ist. Ein boolesches active-Feld, das jetzt „E-Mail verifiziert“ statt „Konto aktiviert“ bedeutet, taucht nicht im Diff auf. Der Typ hat sich nicht geändert. Der Contract schon.

Layer 2: Consumer-Contracts mit Pact verifizieren

Consumer-driven Contract Testing dreht das Modell um. Statt dass der API-Provider seine eigene Korrektheit behauptet, definieren die Consumer, was sie brauchen. Diese Erwartungen werden zu Contracts, die der Provider in der CI erfüllen muss.

So funktioniert es. Ihr Frontend-Team schreibt einen Test, der sagt: „Wenn ich GET /users/123 aufrufe, erwarte ich einen 200 mit einem Body, der user_id als String enthält.“ Pact zeichnet diese Interaktion auf und speichert den Contract.

Auf der Provider-Seite holt sich Ihr Backend-CI alle Consumer-Contracts und spielt sie gegen den aktuellen Code ab. Wenn ein PR user_id entfernt oder in eine Zahl ändert, schlägt die Provider-Verifikation fehl. Auch wenn Ihre eigenen Tests bestanden sind.

Ein minimaler Consumer-Test mit Pact JS:

// consumer.spec.js
const { PactV3 } = require('@pact-foundation/pact');
const { expect } = require('chai');

const provider = new PactV3({
  consumer: 'web-app',
  provider: 'user-service',
  dir: './pacts',
});

describe('GET /users/:id', () => {
  it('returns the user', async () => {
    await provider
      .given('user exists')
      .uponReceiving('a request for user 123')
      .withRequest({
        method: 'GET',
        path: '/users/123',
      })
      .willRespondWith({
        status: 200,
        headers: { 'Content-Type': 'application/json' },
        body: {
          user_id: '123',
          email: 'alice@example.com',
        },
      });

    await provider.executeTest(async (mockserver) => {
      const user = await fetchUser(mockserver.url, '123');
      expect(user.user_id).to.equal('123');
    });
  });
});

Die Provider-Verifikation in der CI:

# .github/workflows/verify-contracts.yml
name: Verify Consumer Contracts

on: [pull_request]

jobs:
  verify:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Verify pacts
        run: |
          docker run --rm \
            -v $(pwd)/pacts:/pacts \
            pactfoundation/pact-cli \
            verify-provider \
            --provider-app-version ${{ github.sha }} \
            --pact-broker-base-url https://your-pact-broker.io \
            --provider user-service

Das erkennt semantische Brüche, die Schema-Diffing nicht sehen kann. Wenn ein Consumer auf ein bestimmtes Wertformat oder eine bestimmte Fehlermeldung angewiesen ist, markiert Pact das.

Die Trade-offs, die die meisten Teams ignorieren

Keiner der Ansätze ist umsonst.

OpenAPI-Diffing erfordert, dass Sie eine korrekte Spezifikation pflegen. Wenn Ihre Spezifikation aus Code-Annotationen generiert wird, bleibt sie automatisch aktuell. Wenn sie von Hand geschrieben ist, driftet sie auseinander, und das Diff wird bedeutungslos. Generierte Spezifikationen sind für diesen Workflow besser.

Pact erfordert organisatorische Disziplin. Consumer müssen Contract Tests schreiben. Sie brauchen einen Broker, um Contracts zu speichern und zu versionieren. Wenn die Erwartung eines Consumers falsch ist, muss jemand die Änderung aushandeln. Das erzeugt Reibung. Das ist der Punkt. Die Reibung verhindert stille Brüche.

Beides zu betreiben ist ideal, aber nicht immer praktikabel. Wenn Sie einen oder zwei kritische Consumer haben, amortisiert sich Pact schnell. Wenn Sie Dutzende anonymer API-Consumer haben, ist OpenAPI-Diffing der bessere Startpunkt.

Noch etwas: Keines der Tools erkennt Performance-Regressionen oder Authentifizierungsänderungen. Ein neuer Endpunkt, der OAuth verlangt, wo vorher keine nötig war, ist ein Breaking Change. Ihr Diff-Tool markiert das vielleicht nicht, wenn das Auth-Schema bereits an anderer Stelle definiert war. Halten Sie die Augen offen für die Lücken.

FAQ

Was ist ein Breaking API Change?

Ein Breaking API Change ist jede Änderung an einer API, die bestehende, korrekt implementierte Clients zum Scheitern bringt. Dazu gehört das Entfernen von Feldern, das Ändern von Typen, das Verpflichtendmachen optionaler Parameter oder das Ändern von Response-Statuscodes für bestehende Endpunkte.

Worin unterscheidet sich API Contract Testing von Integration Testing?

integration tests verifizieren, dass Ihr System als Ganzes funktioniert. Contract Tests verifizieren, dass das öffentliche Interface der API mit dem übereinstimmt, was Consumer erwarten. integration tests können sogar bestehen, wenn der Contract bricht, sofern beide Seiten des Systems im selben Commit aktualisiert werden.

Kann ich OpenAPI-Diffing nutzen, ohne Spezifikationen von Hand zu schreiben?

Ja. Tools wie oasdiff funktionieren mit jedem OpenAPI-Dokument, einschließlich solcher, die aus Code-Annotationen mit Bibliotheken wie SpringDoc, FastAPI oder drf-spectacular generiert wurden. Generierte Spezifikationen sind oft zuverlässiger, weil sie nicht von der Implementierung abweichen können.

Brauche ich einen Pact Broker, um Consumer-driven Contracts zu nutzen?

Für Teams mit mehr als ein paar Services, ja. Der Broker speichert Contracts, verfolgt Versionen und zeigt, welche Consumer von einer vorgeschlagenen Änderung betroffen sind. Ohne ihn tauschen Sie Contract-Dateien manuell aus, was schnell zusammenbricht.

Was ist mit GraphQL?

GraphQL hat andere Semantiken für Breaking Changes. Ein Feld zu entfernen ist breaking, aber eines hinzuzufügen ist sicher. Tools wie GraphQL Inspector bieten Schema-Diffing ähnlich wie OpenAPI-Tools. Pact unterstützt auch GraphQL-Interaktionen.

Fangen Sie mit dem Schema-Diff an

Beginnen Sie mit OpenAPI-Diffing. Es ist der geringste Aufwand und erkennt die häufigsten Brüche. Fügen Sie es noch heute Ihrer CI hinzu, auch wenn Ihre Spezifikation nicht perfekt ist. Ein unvollkommener Check ist besser als keiner.

Sobald Sie einen Bruch im Review gefangen haben, der jemandem das Wochenende ruiniert hätte, verstehen Sie, warum das wichtig ist.