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.
Inhaltsverzeichnis
- 1. Warum grüne Unit-Tests allein nicht reichen
- 2. Das Grundprinzip von Consumer Driven Contracts
- 3. Den Consumer-Test mit PHPUnit schreiben
- 4. Den Provider-Test gegen denselben Vertrag prüfen
- 5. Den Pact Broker als zentrale Vertragsablage nutzen
- 6. Eine schlanke Alternative ohne zusätzliche Bibliothek
- 7. Was Contract Tests bewusst nicht prüfen
- 8. Contract Tests in die CI-Pipeline einbinden
- 9. Fazit: Vertrauen zwischen Services technisch absichern
- 10. Zusammenfassung
- 11. FAQ
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.