Contract Tests zwischen Services in PHP mit PHPUnit umsetzen
AI generated
@test
assert
PHPUnit · Contract Tests · PHP
Contract Tests
Breaking Changes zwischen Services in PHP mit PHPUnit verhindern

Zwei unabhängig deploybare PHP-Services, die sich über eine API verständigen, können jeweils für sich genommen eine grüne Testsuite haben und trotzdem inkompatibel werden. Contract Tests mit PHPUnit schließen genau diese Lücke zwischen Anbieter und Konsument.

15 Min. Lesezeit Consumer Driven Contracts API-Kompatibilität Service-Grenzen

1. Warum grüne Unit-Tests allein nicht reichen

Sobald ein System in mehrere unabhängig deploybare Services aufgeteilt wird, etwa einen Bestellservice und einen davon getrennt betriebenen Preisservice, die per REST- oder GraphQL-API kommunizieren, verschiebt sich ein wesentlicher Teil der Korrektheit an die Schnittstelle zwischen beiden. Jeder Service kann für sich genommen eine perfekt grüne PHPUnit-Suite haben und trotzdem inkompatibel werden, sobald jemand ein Response-Feld umbenennt, einen Pflichtparameter hinzufügt oder ein Datenformat ändert, ohne dass der jeweils andere Service davon erfährt.

Das eigentliche Problem ist, dass klassische Unit- und sogar Integrationstests innerhalb eines einzelnen Services die Annahmen über das Verhalten des anderen Services fest verdrahten, meist über Mocks oder Stubs. Ändert sich die echte API, bleiben diese Mocks unverändert grün, obwohl das reale System längst nicht mehr funktioniert. Genau diese stille Divergenz zwischen Mock-Verhalten und echtem API-Verhalten ist der Kern des Problems, das Contract Tests lösen.

2. Das Grundprinzip von Consumer Driven Contracts

Beim Consumer Driven Contract Testing formuliert der Konsument, also der Service, der eine API aufruft, explizit seine Erwartungen an die Antwort in Form eines maschinenlesbaren Vertrags. Dieser Vertrag wird anschließend gegen den Anbieter, den Service, der die API bereitstellt, wiedergegeben und geprüft, ob die tatsächliche Antwort den Erwartungen entspricht. Beide Seiten testen damit gegen denselben, geteilten Vertrag statt gegeneinander isolierte Annahmen zu pflegen.

Der entscheidende Vorteil gegenüber End-to-End-Tests, bei denen beide Services gemeinsam in einer Testumgebung hochgefahren werden müssen, ist, dass Contract Tests jeden Service isoliert testen können. Der Konsument testet gegen einen aus dem Vertrag generierten Mock-Server, der Anbieter testet gegen denselben Vertrag mit seiner echten Implementierung. Beide Testläufe sind dadurch schnell, unabhängig voneinander ausführbar und benötigen keine gemeinsam laufende Infrastruktur.

3. Den Consumer-Test mit PHPUnit schreiben

Auf Konsumentenseite formuliert man mit einer Bibliothek wie pact-foundation/pact-php zunächst die erwartete Interaktion: welche Anfrage der Konsument stellt und welche Antwortstruktur er erwartet. PHPUnit übernimmt dabei die gewohnte Rolle als Testrunner, während die Pact-Bibliothek im Hintergrund einen Mock-Server startet, der exakt diese Interaktion bedient und gleichzeitig einen Vertrag als JSON-Datei aufzeichnet.

Wichtig ist, im Consumer-Test nur die für den eigenen Service relevanten Felder zu prüfen und nicht die komplette Antwortstruktur des Anbieters eins zu eins zu spiegeln. Wer zu viele Felder in den Vertrag aufnimmt, koppelt sich unnötig eng an Details, die der eigene Service gar nicht verwendet, und erschwert dem Anbieter dadurch spätere, eigentlich harmlose Erweiterungen der API.


<?php

declare(strict_types=1);

namespace Tests\Contract;

use PhpPact\Consumer\InteractionBuilder;
use PhpPact\Consumer\Model\ConsumerRequest;
use PhpPact\Consumer\Model\ProviderResponse;
use PhpPact\Standalone\MockService\MockServerEnvConfig;
use PHPUnit\Framework\TestCase;

final class PriceServiceConsumerTest extends TestCase
{
    public function testFetchesPriceForProduct(): void
    {
        $config = new MockServerEnvConfig();
        $builder = new InteractionBuilder($config);

        $request = (new ConsumerRequest())
            ->setMethod('GET')
            ->setPath('/api/prices/42');

        $response = (new ProviderResponse())
            ->setStatus(200)
            ->addHeader('Content-Type', 'application/json')
            ->setBody(['productId' => 42, 'grossPrice' => 19.99, 'currency' => 'EUR']);

        $builder->given('a price exists for product 42')
            ->uponReceiving('a request for the product price')
            ->with($request)
            ->willRespondWith($response);

        $builder->run(function () use ($config) {
            $client = new PriceServiceClient($config->getBaseUri());
            $price = $client->fetchPrice(42);

            self::assertSame(19.99, $price->grossPrice());
        });
    }
}

4. Den Provider-Test gegen denselben Vertrag prüfen

Auf Anbieterseite läuft der zuvor aufgezeichnete Vertrag gegen die echte, laufende Implementierung des Preisservice. Statt der Mock-Antwort aus dem Consumer-Test wird jetzt die tatsächliche Antwort des Services validiert. Dafür wird typischerweise ein Testserver mit einer bekannten Datenbasis gestartet, die sicherstellt, dass die im Vertrag beschriebenen Vorbedingungen wie 'ein Preis existiert für Produkt 42' auch tatsächlich erfüllt sind.

Schlägt der Provider-Test fehl, bedeutet das konkret: Die Erwartung eines Konsumenten wird von der aktuellen Implementierung nicht mehr erfüllt, und ein Deployment würde diesen Konsumenten brechen. Dieser Fehlschlag passiert bewusst schon in der CI-Pipeline des Anbieters, lange bevor die inkompatible Änderung überhaupt in Produktion gelangen könnte.


<?php

declare(strict_types=1);

namespace Tests\Contract;

use PhpPact\Verifier\Model\VerifierConfig;
use PhpPact\Verifier\InteractionRunner\InteractionRunner;
use PHPUnit\Framework\TestCase;

final class PriceServiceProviderTest extends TestCase
{
    public function testSatisfiesConsumerContract(): void
    {
        $config = (new VerifierConfig())
            ->setProviderName('price-service')
            ->setProviderBaseUrl('http://localhost:8080')
            ->setPactUrl(__DIR__ . '/../pacts/order-service-price-service.json');

        $runner = new InteractionRunner($config);

        self::assertTrue($runner->verify());
    }
}

5. Den Pact Broker als zentrale Vertragsablage nutzen

In kleinen Setups mit zwei Services reicht es, den generierten Vertrag als Datei zwischen den beiden Repositories zu teilen. Sobald mehr als eine Handvoll Services beteiligt sind, wird das schnell unübersichtlich, weshalb sich der Einsatz eines Pact Brokers lohnt: eines zentralen Dienstes, der veröffentlichte Verträge speichert, Versionen verwaltet und eine Matrix anzeigt, welcher Konsument mit welcher Anbieterversion kompatibel ist.

Der Broker ermöglicht außerdem 'can-i-deploy'-Prüfungen direkt in der CI-Pipeline: Bevor ein Anbieter deployt wird, fragt die Pipeline beim Broker nach, ob alle bekannten Konsumenten mit der neuen Version verifiziert wurden. Erst wenn diese Prüfung erfolgreich ist, wird das Deployment freigegeben, was Breaking Changes praktisch unmöglich macht, ohne dass jemand manuell alle abhängigen Teams kontaktieren müsste.

6. Eine schlanke Alternative ohne zusätzliche Bibliothek

Nicht jedes Projekt braucht sofort die volle Pact-Infrastruktur mit Broker und automatisierten Verifikationen. Eine leichtgewichtige Variante lässt sich auch rein mit PHPUnit und JSON-Schema-Validierung umsetzen: Der Konsument definiert ein JSON-Schema für die erwartete Antwortstruktur, und sowohl Consumer- als auch Provider-Tests validieren ihre jeweiligen Daten gegen genau dieses Schema.

Dieser Ansatz ist weniger mächtig als vollwertige Consumer Driven Contracts, weil er keine automatische Verifikationsmatrix zwischen mehreren Services bietet, eignet sich aber gut als Einstieg für Teams mit zwei oder drei eng zusammenarbeitenden Services, die noch keine dedizierte Contract-Testing-Infrastruktur aufbauen wollen, aber trotzdem stille API-Divergenzen frühzeitig erkennen möchten.


<?php

declare(strict_types=1);

namespace Tests\Contract;

use JsonSchema\Validator;
use PHPUnit\Framework\TestCase;

final class PriceResponseSchemaTest extends TestCase
{
    public function testProviderResponseMatchesSchema(): void
    {
        $response = (new PriceServiceClient('http://localhost:8080'))
            ->fetchRawResponse(productId: 42);

        $schema = json_decode(
            file_get_contents(__DIR__ . '/../schema/price-response.schema.json'),
        );

        $validator = new Validator();
        $validator->validate($response, $schema);

        self::assertTrue($validator->isValid(), implode(', ', array_column($validator->getErrors(), 'message')));
    }
}

7. Was Contract Tests bewusst nicht prüfen

Contract Tests prüfen strukturelle und teilweise semantische Kompatibilität, also ob Felder vorhanden sind, welche Typen sie haben und ob erwartete Statuscodes zurückkommen. Sie prüfen aber ausdrücklich nicht die fachliche Korrektheit der Geschäftslogik dahinter, etwa ob ein berechneter Preis tatsächlich richtig ist. Diese Aufgabe bleibt weiterhin bei den klassischen Unit- und Integrationstests des jeweiligen Services.

Ebenso ersetzen Contract Tests keine End-to-End-Tests für kritische Nutzerflüsse, bei denen mehrere Services tatsächlich gemeinsam zusammenspielen müssen. Sie reduzieren aber den Bedarf an solchen teuren, langsamen End-to-End-Tests erheblich, weil die reine Schnittstellenkompatibilität bereits auf einer viel schnelleren und günstigeren Ebene abgesichert ist.

8. Contract Tests in die CI-Pipeline einbinden

Damit Contract Tests ihren Nutzen entfalten, müssen sowohl Consumer- als auch Provider-Tests fest in die jeweilige CI-Pipeline integriert sein und bei jedem Merge in den Hauptzweig automatisch laufen. Der Consumer-Test läuft dabei im Repository des Konsumenten und veröffentlicht bei Erfolg den generierten Vertrag automatisch an den Broker oder das geteilte Repository.

Der Provider-Test läuft im Repository des Anbieters und lädt bei jedem Lauf den aktuellsten veröffentlichten Vertrag herunter, statt eine lokal veraltete Kopie zu verwenden. Nur so ist garantiert, dass eine Änderung am Konsumenten, etwa eine neue Erwartung an ein zusätzliches Feld, auch tatsächlich beim nächsten Provider-Testlauf geprüft wird, statt erst Wochen später aufzufallen.

9. Fazit: Vertrauen zwischen Services technisch absichern

Contract Tests übersetzen das informelle Vertrauen zwischen Teams, dass eine API 'schon irgendwie kompatibel bleiben wird', in eine automatisiert prüfbare Zusicherung. Für PHP-Projekte mit mehreren unabhängig deploybaren Services ist das der wirksamste Weg, Breaking Changes bereits vor dem Deployment sichtbar zu machen statt erst durch einen Produktionsfehler.

Der Einstieg lohnt sich schrittweise: Zunächst reicht ein einfacher, gemeinsam gepflegter Vertrag zwischen zwei eng gekoppelten Services. Erst wenn die Anzahl der Service-zu-Service-Beziehungen wächst, wird die Investition in einen zentralen Pact Broker mit automatisierten Verifikationsmatrizen wirklich spürbar.

Testebene Was geprüft wird Geschwindigkeit Ersetzt sie andere Tests?
Unit-Test Fachliche Logik innerhalb eines Services Sehr schnell Nein, Grundlage jeder Testpyramide
Contract Test (Consumer) Erwartete Antwortstruktur aus Konsumentensicht Schnell, isoliert lauffähig Nein, ergänzt Unit-Tests um Schnittstellensicht
Contract Test (Provider) Tatsächliche Antwort gegen denselben Vertrag Schnell, isoliert lauffähig Nein, prüft nur strukturelle Kompatibilität
End-to-End-Test Zusammenspiel mehrerer echter Services Langsam, hoher Infrastrukturaufwand Wird durch Contract Tests deutlich reduziert, nicht ersetzt

Mironsoft

Testautomatisierung, Magento-Qualitätssicherung und CI-Integration

Tests, die echte Fehler finden statt nur grün zu leuchten?

Wir prüfen bestehende PHPUnit-Suiten auf Implementierungsdetail-Tests, flaky Tests und fehlende Coverage an kritischen Stellen und bauen daraus eine Teststrategie, die bei jedem Magento-Update wirklich Sicherheit gibt.

Test-Audit

Bestehende Suiten auf Mocking-Antipatterns und blinde Flecken prüfen.

Teststrategie

Unit-, Integrations- und MFTF-Tests sinnvoll für Magento-Projekte kombinieren.

CI-Integration

Schnelle, zuverlässige Testläufe in GitLab CI oder GitHub Actions einrichten.

10. Zusammenfassung

Contract Tests: Das Wichtigste auf einen Blick

Kernproblem

Grüne Unit-Tests pro Service verhindern nicht, dass sich zwei Services über eine geänderte API stillschweigend auseinanderentwickeln.

Grundprinzip

Consumer und Provider testen unabhängig voneinander gegen denselben, maschinenlesbaren Vertrag.

Werkzeug

pact-foundation/pact-php integriert sich als gewöhnliche PHPUnit-Testsuite auf beiden Seiten.

Grenzen

Contract Tests prüfen strukturelle Kompatibilität, nicht die fachliche Korrektheit der Geschäftslogik.

11. FAQ: Contract Tests: Das Wichtigste auf einen Blick

1Was ist der Unterschied zwischen Contract Tests und End-to-End-Tests?
Contract Tests prüfen jeden Service isoliert gegen einen geteilten Vertrag, End-to-End-Tests lassen echte Services tatsächlich zusammenspielen. Contract Tests sind schneller, ersetzen End-to-End-Tests aber nicht vollständig.
2Was bedeutet Consumer Driven Contract Testing konkret?
Der Konsument einer API formuliert seine Erwartungen an die Antwort als maschinenlesbaren Vertrag, der anschließend gegen die echte Implementierung des Anbieters verifiziert wird.
3Welche PHP-Bibliothek wird typischerweise für Contract Tests eingesetzt?
pact-foundation/pact-php ist die verbreitetste Implementierung des Pact-Standards für PHP und integriert sich direkt in PHPUnit-Testsuiten.
4Was ist ein Pact Broker?
Ein zentraler Dienst, der veröffentlichte Verträge speichert, Versionen verwaltet und über can-i-deploy-Prüfungen verhindert, dass ein Anbieter mit inkompatiblen Änderungen deployt wird.
5Braucht man für Contract Tests zwingend Pact?
Nein. Eine leichtgewichtige Alternative ist JSON-Schema-Validierung, bei der Consumer- und Provider-Tests dieselbe Schema-Datei gegen ihre jeweiligen Daten prüfen.
6Was prüfen Contract Tests nicht?
Sie prüfen keine fachliche Korrektheit der Geschäftslogik, etwa ob ein berechneter Preis stimmt. Dafür bleiben klassische Unit- und Integrationstests zuständig.
7Wie oft sollten Contract Tests laufen?
Bei jedem Merge in den Hauptzweig, sowohl im Consumer- als auch im Provider-Repository, damit Änderungen sofort und nicht erst Wochen später auffallen.
8Ersetzen Contract Tests End-to-End-Tests komplett?
Nein, sie reduzieren aber den Bedarf an teuren End-to-End-Tests erheblich, weil reine Schnittstellenkompatibilität bereits auf einer schnelleren Ebene abgesichert wird.
9Ab welcher Projektgröße lohnt sich ein Pact Broker?
Bei zwei eng gekoppelten Services reicht oft ein einfach geteilter Vertrag. Ab mehreren Service-zu-Service-Beziehungen wird ein zentraler Broker mit Verifikationsmatrix spürbar wertvoller.
10Wie verhindert can-i-deploy konkret Breaking Changes?
Die CI-Pipeline fragt vor einem Deployment beim Broker ab, ob alle bekannten Konsumenten bereits erfolgreich gegen die neue Anbieterversion verifiziert wurden, und blockiert das Deployment sonst.