Contract Testing mit Pact: API-Integrationsfehler verhindern
Ein Backend-Team liefert am Dienstag ein Release aus. Eine kleine Aufräumarbeit benennt ein Feld in einer API-Antwort um: aus customer_id wird customerId, um zum Rest des Schemas zu passen. Die Änderung besteht jeden Test in der Backend-Pipeline. Das Deploy wird grün. Dreißig Minuten später beginnt der mobile Checkout-Flow leere Bildschirme zurückzugeben, weil das Frontend immer noch customer_id liest, und niemandem ist es aufgefallen.
Dieses Muster ist so verbreitet, dass die meisten Teams aufgehört haben, sich davon überraschen zu lassen. Die Integration zwischen einem Frontend und einem Backend ist eine der dünnsten, brüchigsten Schnittstellen in moderner Software, und die üblichen Verteidigungen, End-to-End-Tests, Staging-Umgebungen, manuelles QA, fangen Fehler spät ab, wenn überhaupt. Contract Testing, und speziell das Pact-Framework, existiert, um diese Lücke zu schließen. Statt den gesamten Stack auszuführen, um Integrationen zu verifizieren, kodiert es die genauen Erwartungen, die zwei Dienste aneinander haben, und führt sie als schnelle, isolierte Tests auf beiden Seiten aus.
Dieser Beitrag geht durch, was Contract Testing tatsächlich ist, wo es im Verhältnis zu den Test-Tools passt, die du bereits nutzt, und wie man es praktisch in einer PHP/Symfony- und Next.js-Umgebung einrichtet. Er richtet sich an Teams, die den Schmerz von API-Brüchen in der Produktion gespürt haben und eine strukturelle Lösung statt mehr Prozess wollen.
Warum End-to-End-Testing nicht die Antwort ist
Der Instinkt, wenn ein Frontend und ein Backend auseinanderlaufen, ist, mehr End-to-End-Tests hinzuzufügen. Die Theorie ist solide: beide Dienste ausführen, echte Requests stellen, die Ausgaben prüfen. In der Praxis scheitert dieser Ansatz vorhersehbar.
End-to-End-Tests sind langsam. Eine Suite, die Datenbanken hochfährt, Fixtures lädt, das Backend bootet, das Frontend rendert und Schlüsselabläufe durchspielt, kann leicht zwanzig Minuten zur Ausführung brauchen. Teams reagieren, indem sie sie nächtlich statt bei jedem Commit ausführen, was bedeutet, dass Integrationsbrüche Stunden oder Tage nach ihrer Einführung abgefangen werden, lange nachdem der Entwickler weitergezogen ist und der Kontext teuer wiederherzustellen ist.
End-to-End-Tests sind brüchig. Sie scheitern aus Gründen, die nichts mit dem getesteten Code zu tun haben: eine langsame Datenbank, ein Netzwerk-Aussetzer, ein Timing-Problem im Frontend-Render, eine gemeinsam genutzte Test-Datenbank, die von einem parallelen Lauf verändert wird. Teams beginnen, Fehler als flaky zu markieren, sie automatisch erneut auszuführen und schließlich der Suite gar nicht mehr zu vertrauen.
Am wichtigsten: End-to-End-Tests isolieren den Fehler nicht. Wenn die Suite rot wird, weißt du, dass etwas über zwei Dienste, eine Datenbank, eine Queue und einen Browser hinweg falsch ist. Festzustellen, ob es eine Backend-Regression, ein Frontend-Bug oder eine Infrastruktur-Flake war, erfordert echte Untersuchung. Das Signal-Rausch-Verhältnis ist niedrig.
End-to-End-Abdeckung hat weiterhin ihren Platz, für das Smoke-Testing kritischer Nutzerabläufe, aber sie ist das falsche Werkzeug, um API-Kompatibilität zu validieren. Das richtige Werkzeug ist Contract Testing.
Was Contract Testing tatsächlich verifiziert
Ein Contract ist eine präzise Spezifikation einer Interaktion zwischen zwei Diensten: dem Consumer (typischerweise das Frontend oder ein vorgelagerter Dienst) und dem Provider (typischerweise die Backend-API). Ein einzelner Contract erfasst den genauen Request, den der Consumer stellen wird, Methode, Pfad, Header, Body-Form, und die genaue Antwort, die er erwartet, einschließlich Statuscode, Header und Body-Struktur.
Contract-Tests führen diese Spezifikation unabhängig gegen beide Seiten aus. Auf der Consumer-Seite startet das Framework einen Mock-Server, der gemäß dem Contract antwortet, und der echte Consumer-Code wird dagegen ausgeführt. Wenn das Frontend einen fehlerhaften Request sendet oder bricht, wenn eine dokumentierte Antwortform zurückgegeben wird, schlägt der Test fehl. Auf der Provider-Seite spielt das Framework die aufgezeichneten Requests gegen den echten Provider ab und verifiziert, dass die Antworten dem Contract entsprechen. Wenn das Backend ein Feld umbenannt, eine erforderliche Eigenschaft entfernt oder einen Statuscode geändert hat, schlägt der Test fehl.
Die entscheidende Eigenschaft ist, dass diese beiden Seiten isoliert laufen. Der Consumer braucht nicht, dass das Backend läuft. Der Provider braucht das Frontend nicht. Die Contract-Datei, ein JSON-Dokument, ist die einzige Quelle der Wahrheit, die zwischen ihnen wandert, typischerweise über einen Broker oder Artefakt-Store.
Deshalb wird Contract Testing manchmal consumer-getriebenes Contract Testing genannt: Der Contract wird von der Test-Suite des Consumers erzeugt und erfasst, was der Consumer tatsächlich braucht, nicht was der Provider zufällig zurückgibt. Der Provider wird dann zur Rechenschaft gezogen, genau das zu liefern. Wenn ein Backend-Team eine Antwortform ändern will, schlägt der Contract-Test auf der Provider-Seite sofort fehl, und die Änderung wird zu einer bewussten, koordinierten Entscheidung statt zu einer versehentlichen brechenden Änderung, die in der Produktion entdeckt wird.
Wie Pact End-to-End funktioniert
Pact ist das am weitesten verbreitete Open-Source-Framework für Contract Testing. Es hat ausgereifte Client-Bibliotheken für PHP, JavaScript/TypeScript, Go, Java, .NET, Python und Ruby, was es realistisch macht, es in polyglotten Teams zu nutzen.
Der Workflow hat vier Stufen. Erstens schreibt das Consumer-Team Pact-Tests, die die Interaktionen beschreiben, von denen es abhängt, und führt sie gegen einen lokalen Pact-Mock-Server aus. Diese Tests erzeugen eine Contract-Datei, ein .json-Pact-Dokument, das jede verifizierte Interaktion auflistet. Zweitens veröffentlicht die Consumer-Pipeline den Contract bei einem Pact Broker, einem leichtgewichtigen gemeinsamen Server, der Contracts nach Consumer- und Provider-Namen und -Versionen indexiert speichert. Drittens holt die Provider-Pipeline die neuesten für sie relevanten Contracts vom Broker und führt die Verifizierung aus: Sie spielt die aufgezeichneten Requests ab und vergleicht die Antworten. Viertens zeichnet der Broker das Verifizierungsergebnis auf und erzeugt eine Kompatibilitätsmatrix, die zeigt, welche Consumer-Versionen mit welchen Provider-Versionen funktionieren.
Der praktische Effekt ist, dass das Provider-Team ein Signal zur Pull-Request-Zeit erhält: "Wenn du diese Änderung mergst, brichst du Consumer X in Umgebung Y." Dieses Signal ist der ganze Sinn. Die Kosten, eine brechende API-Änderung zu beheben, sind vor dem Merge trivial. Sie sind schmerzhaft, sobald sie im Staging ist, und katastrophal in der Produktion.
Ein PHP-Provider, ein Next.js-Consumer
In einem typischen Wolf-Tech-Auftrag ist die Aufteilung ein Symfony-Backend, das eine JSON-API bereitstellt, und ein Next.js-Frontend, das sie konsumiert. Beide Seiten können Pact nutzen, und das Setup ist nicht exotisch.
Auf der Consumer-Seite, einer Next.js-Anwendung, die @pact-foundation/pact für Node nutzt, beschreibt ein Contract-Test eine Interaktion, von der das Frontend abhängt:
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import path from 'path';
import { fetchCustomer } from './customer-client';
const provider = new PactV3({
consumer: 'web-frontend',
provider: 'customer-api',
dir: path.resolve(process.cwd(), 'pacts'),
});
describe('Customer API client', () => {
it('returns customer details by id', async () => {
provider
.given('a customer with id 42 exists')
.uponReceiving('a request for customer 42')
.withRequest({
method: 'GET',
path: '/api/customers/42',
headers: { Accept: 'application/json' },
})
.willRespondWith({
status: 200,
headers: { 'Content-Type': 'application/json' },
body: {
id: MatchersV3.integer(42),
displayName: MatchersV3.string('Ada Lovelace'),
email: MatchersV3.email('ada@example.com'),
createdAt: MatchersV3.iso8601DateTime(),
},
});
await provider.executeTest(async (mock) => {
const customer = await fetchCustomer(mock.url, 42);
expect(customer.displayName).toBe('Ada Lovelace');
});
});
});
Beachte die Verwendung von Matchern statt literaler Werte für id, displayName, email und createdAt. Das ist beabsichtigt. Der Contract muss die Form erfassen, von der der Consumer abhängt, nicht spezifische Fixture-Werte. Matcher dokumentieren, dass das Frontend einen Integer, einen nicht-leeren String, eine gültige E-Mail und einen ISO-8601-Zeitstempel braucht, nicht mehr. Wenn der Provider einen anderen Typ zurückgibt, schlägt die Verifizierung fehl. Wenn der Provider andere spezifische Werte zurückgibt, besteht die Verifizierung.
Auf der Provider-Seite, einer Symfony-Anwendung, die das Paket pact-php nutzt, sieht die Verifizierung so aus:
<?php
use PhpPact\Standalone\ProviderVerifier\Model\VerifierConfig;
use PhpPact\Standalone\ProviderVerifier\Verifier;
use PhpPact\Standalone\ProviderVerifier\Model\Source\BrokerSource;
$config = (new VerifierConfig())
->setProviderInfo((new \PhpPact\Consumer\Model\ProviderInfo())
->setName('customer-api')
->setHost('localhost')
->setPort(8080))
->setProviderVersion(getenv('GIT_SHA'))
->setProviderBranch(getenv('GIT_BRANCH'))
->setPublishResults(true);
$verifier = new Verifier($config);
$verifier->addSource(
new BrokerSource(
new \Psr\Http\Message\UriInterface('https://pact.internal'),
'ci-token'
)
);
$verifier->verify();
Vor der Verifizierung muss der Provider in einem bekannten Zustand laufen. Die Zeile given('a customer with id 42 exists') im Consumer-Test wird zu einem Provider-State, einem Hook, den die Symfony-Anwendung registriert, um die Datenbank zu befüllen, damit die erwartete Interaktion gelingen kann:
#[Route('/_pact/provider-states', methods: ['POST'])]
public function provideState(Request $request): JsonResponse
{
$state = $request->toArray()['state'] ?? null;
match ($state) {
'a customer with id 42 exists' => $this->seedCustomer(42, 'Ada Lovelace'),
default => null,
};
return new JsonResponse(['result' => 'ok']);
}
Das ist das Stück, das Teams, die neu bei Pact sind, oft überrascht: Provider-States sind keine Fixtures. Sie sind aufrufbare Setup-Funktionen, und sie sind Teil des Contracts. Beide Seiten müssen sich einig sein, dass "a customer with id 42 exists" eine konsistente Bedeutung hat, was üblicherweise durchgesetzt wird, indem die State-Namen in einem gemeinsamen Register gehalten oder beim Schnittstellen-Design gemeinsam überprüft werden.
Einbau in die CI
Contract-Tests liefern ihren Wert nur, wenn sie bei jeder Änderung laufen und Merges bei Fehlern blockieren. Das praktische CI-Setup hat drei bewegliche Teile.
Auf der Consumer-Seite laufen die Pact-Tests als Teil der Standard-Test-Suite. Wenn sie bestehen, veröffentlicht die Pipeline den resultierenden Contract beim Broker, getaggt mit dem Branch und der Version des Consumers. Das Tool can-i-deploy fragt dann den Broker, ob diese spezifische Consumer-Version mit dem aktuell deployten Provider in jeder Zielumgebung kompatibel ist. Wenn die Antwort nein lautet, weil der Provider noch nicht aktualisiert wurde, um eine neue Interaktion zu unterstützen, wird das Deploy blockiert.
Auf der Provider-Seite löst jeder Pull Request einen Job aus, der alle Consumer-Contracts zieht, die derzeit in der Produktion deployt sind (und im Staging, je nach Richtlinie), und die Verifizierung gegen den neuen Code ausführt. Wenn die Änderung einen deployten Consumer brechen würde, wird der PR mit einer präzisen Fehlermeldung blockiert, die den Consumer, die Interaktion und das geänderte Feld benennt.
Richtig gemacht, eliminiert das die Klasse von Bugs, die diesen Beitrag motiviert hat. Eine Feldumbenennung kann die Produktion nicht erreichen, ohne dass das Consumer-Team davon weiß, weil ihre Contract-Verifizierung im Provider-PR fehlschlägt. Ein neuer erforderlicher Query-Parameter kann nicht stillschweigend hinzugefügt werden, weil der bestehende Contract des Consumers ihn nicht enthält.
Wann Contract Testing nicht das Richtige ist
Contract Testing ist mächtig, aber es ist kein universeller Ersatz für andere Testarten. Es validiert Schnittstellen, nicht Geschäftslogik. Ein Provider kann jeden Contract-Test bestehen und dennoch die falsche Summe berechnen, weil der Contract nur verifiziert, dass die Antwortform korrekt ist, nicht dass der Wert richtig ist. Unit-Tests und Integrationstests für die Geschäftslogik des Providers bleiben essenziell.
Contract Testing ist auch weniger nützlich für öffentliche APIs mit vielen unbekannten Consumern. Das consumer-getriebene Modell funktioniert, weil du weißt, wer deine Consumer sind, und sie am Contract teilnehmen. Eine öffentliche REST-API mit Tausenden externer Nutzer wird besser durch strenge Versionierung, Deprecation-Richtlinien und OpenAPI-Schema-Validierung bedient.
Für interne APIs zwischen Teams, die dir gehören, ist Contract Testing jedoch nahezu ein Selbstläufer. Die Kosten sind moderat, ein paar Tage Setup, dann laufende Test-Wartung ähnlich jeder anderen Suite, und der Nutzen ist die Eliminierung einer ganzen Kategorie von Produktionsvorfällen.
Es zum Teil eurer Auslieferung machen
Pact in eine bestehende Codebasis einzuführen ist meist ein schrittweiser Prozess. Beginne mit einer hochfrequentierten Interaktion zwischen einem Consumer und einem Provider, weise den Wert nach und expandiere von dort. Eine verbreitete Wolf-Tech-Empfehlung ist, die Einführung von Contract-Tests mit einem breiteren Code-Quality-Audit oder einem individuellen Softwareentwicklungs-Auftrag zu koppeln, weil die Stellen, an denen Contracts am wertvollsten sind, dünne, brüchige, sich häufig ändernde API-Grenzen, auch die Stellen sind, an denen sich andere architektonische Probleme zu häufen pflegen.
Wenn dein Team mit häufigen Frontend-Backend-Integrationsbrüchen, unzuverlässigen End-to-End-Tests oder schmerzhafter Koordination zwischen Teams kämpft, die in unterschiedlichen Taktungen ausliefern, ist Contract Testing fast sicher Teil der Lösung. Erreiche uns unter hello@wolf-tech.io oder besuche wolf-tech.io für eine kostenlose Beratung, wie du Pact in deinen Stack einführst, ohne die aktuelle Auslieferung zu verlangsamen.

