von Contract Tests bis zur Deserialisation-Validierung
REST-APIs und DTO-Mapping sind zwei der häufigsten Fehlerquellen in PHP-Projekten – und zwei der am schwersten zu testenden Bereiche, wenn die Strategie fehlt. Contract Tests, Schema-Validierung und saubere HTTP-Stubbing-Patterns machen API-Tests reproduzierbar, schnell und aussagekräftig.
Inhaltsverzeichnis
- 1. Die Herausforderung beim Testen von APIs
- 2. Contract Tests: API-Verträge absichern
- 3. HTTP-Stubbing mit MockHandler
- 4. DTO-Mapping und Deserialisation testen
- 5. Schema-Validierung im Test
- 6. Deserialisation-Grenzwerte und Fehlerfälle
- 7. Readonly DTOs und PHP 8.4
- 8. Verschiedene API-Response-Varianten testen
- 9. Teststrategie-Vergleich für API-Projekte
- 10. Zusammenfassung
- 11. FAQ
1. Die Herausforderung beim Testen von APIs
REST-APIs bringen eine spezifische Testkomplexität mit sich: Sie haben eine Außengrenze (HTTP), ein Datenformat (JSON/XML), einen Mapping-Schritt (DTO-Deserialisation) und Business-Logik im Inneren. Jede dieser Schichten kann eigene Fehler einführen. Eine Teststrategie, die nur die Business-Logik prüft, übersieht, dass schon das Parsing einer leicht veränderten API-Response den gesamten Stack zum Absturz bringen kann.
Das fundamentale Problem: Eine echte HTTP-Verbindung im Test einzusetzen macht Tests langsam, fragil und vom Netzwerk abhängig. Gleichzeitig führt das vollständige Mocken des HTTP-Clients dazu, dass man nicht mehr testet, ob der eigene Code korrekt mit der echten API-Response umgeht. Die Lösung liegt in einer Schichtenstrategie: HTTP-Stubbing für Unit-Tests, aufgezeichnete Responses für Integration-Tests, Contract Tests für API-Kompatibilität.
In PHP-Projekten mit Symfony, Laravel oder Slim gibt es dafür etablierte Patterns. Guzzle's MockHandler erlaubt es, HTTP-Responses in Tests zu simulieren, ohne die Netzwerkschicht zu verlassen. Symfony's HttpClientInterface kann durch einen MockHttpClient ersetzt werden. Und für DTO-Mapping bietet PHPUnit präzise Assertion-Methoden, die exakt prüfen, ob das Deserialisation-Ergebnis dem erwarteten Objekt entspricht.
2. Contract Tests: API-Verträge absichern
Ein Contract Test stellt sicher, dass eine API-Response die erwartete Struktur und Typen enthält – unabhängig davon, ob die Business-Logik korrekt damit umgeht. Contract Tests sind besonders wichtig bei externen APIs, die sich ohne eigene Kontrolle ändern können: Payment-Provider, Versanddienste, ERP-Systeme. Sie prüfen nicht, ob der eigene Code korrekt ist, sondern ob der externe Partner seinen Vertrag einhält.
In PHP implementiert man Contract Tests am einfachsten als PHPUnit-Tests, die gegen eine aufgezeichnete oder gemockte Response laufen und mit JSON Schema oder Symfony Validator prüfen, ob alle erforderlichen Felder vorhanden und korrekt typisiert sind. Wenn ein externer Dienst seine API-Version ändert, schlägt der Contract Test sofort an – lange bevor der Fehler in der Produktion sichtbar wird. Für Microservice-Architekturen eignet sich Pact als dediziertes Contract-Testing-Framework, das Consumer-Driven Contracts zwischen Services verwaltet.
<?php
declare(strict_types=1);
namespace Tests\Contract\Payment;
use App\Infrastructure\Payment\PaymentGatewayClient;
use App\Infrastructure\Payment\Dto\PaymentResponse;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Psr7\Response;
use PHPUnit\Framework\TestCase;
/**
* Contract test: verifies PaymentGateway API response structure.
*/
final class PaymentGatewayContractTest extends TestCase
{
/** @test */
public function it_deserializes_successful_payment_response_with_all_required_fields(): void
{
$fixture = json_encode([
'id' => 'pay_abc123',
'status' => 'captured',
'amount' => 4990,
'currency' => 'EUR',
'createdAt' => '2026-05-09T10:00:00Z',
]);
$mock = new MockHandler([new Response(200, ['Content-Type' => 'application/json'], $fixture)]);
$client = new Client(['handler' => HandlerStack::create($mock)]);
$gateway = new PaymentGatewayClient($client);
$response = $gateway->capture('pay_abc123', 4990);
self::assertInstanceOf(PaymentResponse::class, $response);
self::assertSame('pay_abc123', $response->id);
self::assertSame('captured', $response->status);
self::assertSame(4990, $response->amount);
self::assertSame('EUR', $response->currency);
}
}
3. HTTP-Stubbing mit MockHandler
HTTP-Stubbing ist der wichtigste Baustein für schnelle, deterministische API-Tests. Statt eine echte HTTP-Verbindung aufzubauen, liefert ein MockHandler vordefinierte Responses zurück. Bei Guzzle passiert das über MockHandler und HandlerStack. Bei Symfony's HTTP-Client wird der MockHttpClient mit einer Liste von MockResponse-Objekten konfiguriert. Beide Ansätze erlauben es, HTTP-Fehler, Timeouts und unerwartete Status-Codes zu simulieren – Szenarien, die mit einer echten API nur schwer reproduzierbar wären.
Im Test sollte die aufgezeichnete Response aus einer Fixture-Datei geladen werden, nicht aus dem Test-Code inline hartcodiert sein. Das macht die Tests lesbarer, erlaubt das Aktualisieren der Fixtures wenn sich die API ändert, und trennt die Test-Logik von den Testdaten. Eine empfehlenswerte Konvention: Fixture-Dateien liegen in tests/Fixtures/Http/ mit sprechenden Namen wie payment-gateway-capture-success.json und payment-gateway-capture-declined.json.
4. DTO-Mapping und Deserialisation testen
DTO-Mapping ist der Schritt, bei dem ein JSON-Payload in ein PHP-Objekt umgewandelt wird. Das klingt trivial, ist aber eine häufige Fehlerquelle: Typfehler (String statt Integer), optionale Felder, die fehlen können, verschachtelte Objekte, Datumswerte als Strings. Im Test muss jedes dieser Szenarien explizit abgedeckt werden – mit Fixture-Daten, die genau die Inputs simulieren, die die echte API liefern kann.
Mit Symfony Serializer erfolgt die Deserialisation über $serializer->deserialize($json, PaymentResponse::class, 'json'). Im Test wird der Serializer direkt instanziiert – ohne Symfony-Kernel, ohne Container. Das macht den Test schnell und isoliert. Die Assertions prüfen nicht nur ob das Objekt erstellt wurde, sondern jeden einzelnen Feldwert: Typ, Wert, verschachtelte Objekte, Nullable-Felder.
<?php
declare(strict_types=1);
namespace Tests\Unit\Infrastructure\Payment;
use App\Infrastructure\Payment\Dto\PaymentResponse;
use App\Infrastructure\Payment\Dto\PaymentMethodDetails;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
/**
* Unit tests for PaymentResponse DTO deserialization.
*/
final class PaymentResponseDtoTest extends TestCase
{
private Serializer $serializer;
protected function setUp(): void
{
$this->serializer = new Serializer(
[new ObjectNormalizer()],
[new JsonEncoder()]
);
}
/** @test */
public function it_maps_all_fields_from_json_to_dto_correctly(): void
{
$json = file_get_contents(__DIR__ . '/../../Fixtures/Http/payment-capture-success.json');
/** @var PaymentResponse $dto */
$dto = $this->serializer->deserialize($json, PaymentResponse::class, 'json');
self::assertSame('pay_abc123', $dto->id);
self::assertSame('captured', $dto->status);
self::assertSame(4990, $dto->amount);
self::assertSame('EUR', $dto->currency);
self::assertInstanceOf(\DateTimeImmutable::class, $dto->createdAt);
self::assertSame('2026-05-09', $dto->createdAt->format('Y-m-d'));
}
/** @test */
public function it_handles_nullable_method_details_field(): void
{
$json = '{"id":"pay_xyz","status":"pending","amount":1000,"currency":"EUR","createdAt":"2026-05-09T10:00:00Z","methodDetails":null}';
/** @var PaymentResponse $dto */
$dto = $this->serializer->deserialize($json, PaymentResponse::class, 'json');
self::assertNull($dto->methodDetails);
}
}
5. Schema-Validierung im Test
Schema-Validierung prüft, ob eine JSON-Response das erwartete Format hat, bevor die Deserialisation versucht wird. Das ist besonders hilfreich, wenn man nicht kontrolliert, welche Felder eine externe API zurückgibt. Mit justinrainbow/json-schema lässt sich in PHPUnit ein JSON-Schema-Validator instanziieren, der das Schema aus einer Datei lädt und gegen die API-Response validiert. Schlägt die Validierung fehl, ist der Fehler präzise: Welches Feld fehlt, welcher Typ ist falsch.
Schema-Tests sind außerdem hervorragend als Regression-Tests geeignet. Wenn eine externe API eine neue Feldversion einführt oder ein optionales Feld zu einem Pflichtfeld macht, zeigt der Schema-Test sofort, wo die Inkompatibilität liegt. Im Unterschied zum Contract Test auf Objektebene prüft der Schema-Test die rohe JSON-Struktur – unabhängig davon, wie der PHP-Code damit umgeht. Das macht ihn zum frühestmöglichen Sicherheitsnetz in der API-Integrationskette.
6. Deserialisation-Grenzwerte und Fehlerfälle
Deserialisation-Grenzwerte sind die Testszenarien, die in der Praxis am häufigsten fehlen: Was passiert, wenn ein erwartetes Feld komplett fehlt? Was, wenn eine Zahl als String geliefert wird? Was, wenn ein verschachteltes Objekt durch ein leeres Objekt ersetzt wird? Diese Szenarien treten in der Produktion regelmäßig auf – durch API-Versionsänderungen, durch Spezialfälle in Backend-Systemen oder durch Netzwerkfehler, die eine Partial-Response liefern.
Im PHPUnit-Test werden diese Grenzwerte als separate Testmethoden oder als Data Provider abgedeckt. Für jeden Grenzwertfall gibt es eine eigene Fixture-Datei oder einen eigenen JSON-String, der genau das Extremszenario darstellt. Die Assertion prüft dann entweder, dass das DTO korrekt erstellt wird (mit Defaults für optionale Felder), oder dass eine spezifische Exception geworfen wird. Letzteres muss mit expectException() und einer präzisen Exception-Message-Assertion geprüft werden.
<?php
declare(strict_types=1);
namespace Tests\Unit\Infrastructure\Shipping;
use App\Infrastructure\Shipping\Dto\ShipmentTrackingDto;
use App\Infrastructure\Shipping\Exception\MalformedTrackingResponseException;
use App\Infrastructure\Shipping\ShipmentResponseMapper;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
/**
* Edge cases for ShipmentResponseMapper — covers missing and malformed fields.
*/
final class ShipmentResponseMapperEdgeCasesTest extends TestCase
{
/** @return array<string, array{string}> */
public static function malformedPayloads(): array
{
return [
'missing trackingNumber field' => ['{"status":"in_transit"}'],
'trackingNumber is null' => ['{"trackingNumber":null,"status":"in_transit"}'],
'status is unexpected enum value' => ['{"trackingNumber":"TK123","status":"UNKNOWN_VALUE"}'],
'completely empty object' => ['{}'],
];
}
#[DataProvider('malformedPayloads')]
public function it_throws_for_malformed_api_payloads(string $json): void
{
$this->expectException(MalformedTrackingResponseException::class);
(new ShipmentResponseMapper())->map(json_decode($json, true));
}
/** @test */
public function it_maps_optional_estimated_delivery_as_null_when_absent(): void
{
$payload = ['trackingNumber' => 'TK999', 'status' => 'shipped'];
$dto = (new ShipmentResponseMapper())->map($payload);
self::assertInstanceOf(ShipmentTrackingDto::class, $dto);
self::assertNull($dto->estimatedDelivery);
}
}
7. Readonly DTOs und PHP 8.4
PHP 8.4 und moderne readonly Properties verändern, wie DTOs in Tests erstellt werden. Ein readonly DTO kann nicht nachträglich modifiziert werden – das ist genau die Garantie, die man für unveränderliche API-Responses will. Im Test bedeutet das: DTOs werden entweder über den Konstruktor mit konkreten Werten erstellt, oder über den Deserializer, der intern den Konstruktor aufruft. Es gibt keinen nachträglichen Setter-Aufruf mehr.
Für Tests ergibt sich daraus eine wichtige Konsequenz: Fixture-Objekte können nicht mehr durch partielle Modifikation erstellt werden. Stattdessen braucht man entweder Named Arguments im Konstruktor (was PHP 8.0+ erlaubt) oder Factory-Methoden im Test-Namespace, die ein vollständiges Test-DTO mit sinnvollen Defaults erstellen. Das Object Mother-Pattern eignet sich hervorragend: Eine statische Factory-Klasse PaymentResponseMother mit Methoden wie captured(), declined(), pending(), die vollständige Test-DTOs zurückgeben.
8. Verschiedene API-Response-Varianten testen
Eine gut getestete API-Integration deckt alle Response-Varianten ab: Erfolg (200), Validierungsfehler (422), Authentifizierungsfehler (401), Service-Unavailable (503) und Timeout. Für jede dieser Varianten gibt es eine eigene Fixture und einen eigenen Test. Der Test prüft nicht nur, ob keine Exception fliegt, sondern auch, welches Verhalten der eigene Code zeigt: Wird ein Retry durchgeführt? Wird eine Domain-Exception geworfen? Wird ein Fallback-Wert zurückgegeben?
Mit Guzzle's MockHandler können auch Timeout-Szenarien simuliert werden, indem statt einer Response ein ConnectException-Objekt in die Handler-Queue gelegt wird. Das ist der einzige Weg, einen Timeout reproduzierbar im Test zu triggern. Für Retry-Logik ist es wichtig zu testen, wie viele Versuche stattfinden, ob die Wartezeit zwischen Versuchen korrekt ist, und ob nach Erschöpfung aller Versuche der richtige Fehler propagiert wird.
9. Teststrategie-Vergleich für API-Projekte
Je nach Abhängigkeit und Risiko eignen sich unterschiedliche Test-Ansätze für API-Integrationen. Die folgende Tabelle gibt einen Überblick über die wichtigsten Teststufen und wann welcher Ansatz sinnvoll ist.
| Test-Typ | Werkzeug | Prüft | Wann einsetzen |
|---|---|---|---|
| DTO-Unit-Test | PHPUnit + Serializer | Feldmapping, Typen, Grenzwerte | Immer – für jede DTO-Klasse |
| HTTP-Stubbing | Guzzle MockHandler | Client-Logik, Error-Handling | Für alle API-Client-Klassen |
| Contract Test | PHPUnit + JSON Schema | API-Response-Struktur | Bei externen APIs ohne eigene Kontrolle |
| Integration Test | Symfony WebTestCase | HTTP → DTO → Business-Logik | Für kritische End-to-End-Flows |
| Live-API-Test | PHPUnit + echte Verbindung | Echte API-Kompatibilität | Nur in dedizierten Smoke-Test-Pipelines |
Die Teststrategie-Pyramide gilt auch für API-Tests: Viele schnelle DTO-Unit-Tests bilden die Basis, darüber kommen HTTP-Stubbing-Tests für die Client-Logik, dann wenige Contract Tests und Integration-Tests. Live-API-Tests sind keine kontinuierliche CI-Tests, sondern werden in dedizierten Monitoring-Pipelines oder Staging-Deployments ausgeführt.
Mironsoft
PHP API-Entwicklung, Teststrategien und DTO-Architektur
API-Integrationen, die auch bei Änderungen stabil bleiben?
Wir entwickeln API-Integrationen mit vollständiger Teststrategie – Contract Tests, DTO-Mapping-Tests und HTTP-Stubbing-Patterns, die Ihre API-Schicht auch bei externen Änderungen früh warnen.
API-Teststrategie
Von DTO-Unit-Tests bis Contract Tests – vollständige Teststrategie für Ihre API-Integrationen
DTO-Architektur
Readonly DTOs, Object Mother Pattern und Fixture-Management für wartbare API-Tests
CI-Integration
Contract Tests und Schema-Validierung in CI-Pipelines integrieren – frühe Warnung bei API-Änderungen
10. Zusammenfassung
Eine vollständige Teststrategie für APIs und DTO-Mapping in PHP besteht aus mehreren aufeinander aufbauenden Schichten. DTO-Unit-Tests prüfen isoliert, ob die Deserialisation korrekte PHP-Objekte erzeugt. HTTP-Stubbing mit MockHandler macht API-Client-Tests schnell und deterministisch. Contract Tests mit JSON-Schema sichern externe API-Verträge ab. Integration-Tests prüfen den Gesamtfluss von HTTP-Request bis zur Business-Logik-Antwort.
Grenzwertfälle – fehlende Felder, Typ-Abweichungen, leere Objekte – sind die häufigsten Quellen von Produktionsfehlern in API-Integrationen. PHP 8.4 mit readonly DTOs und Named Arguments macht Testfixtures präziser und testbarer. Das Object-Mother-Pattern und Fixture-Dateien in einem dedizierten Verzeichnis sorgen für wartbare Testdaten, die sich unabhängig vom Test-Code aktualisieren lassen.
API-Teststrategien — Das Wichtigste auf einen Blick
HTTP-Stubbing
Guzzle MockHandler / Symfony MockHttpClient – macht API-Tests netzwerkunabhängig, deterministisch und schnell. Fixtures aus JSON-Dateien laden, nicht hartcodiert.
Contract Tests
JSON-Schema-Validierung gegen API-Responses – frühe Warnung bei externen API-Änderungen, unabhängig von der eigenen Business-Logik.
DTO-Grenzwerte
Jedes optionale Feld, jeder Null-Wert, jeder Typ-Abweichung – als eigener Test oder Data Provider abgedeckt. Grenzwerte sind die häufigste Fehlerquelle.
Object Mother Pattern
Statische Factory-Klassen für Test-DTOs mit benannten Zuständen (captured, declined, pending). Kein Copy-Paste von Fixture-Daten zwischen Tests.