@test
assert
PHPUnit · API Testing · DTO-Mapping · Contract Tests
PHPUnit Teststrategien für APIs und DTO-Mapping
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.

14 Min. Lesezeit Contract Tests · DTO · Symfony Serializer · HTTP Stubbing PHPUnit 11 · PHP 8.4 · Symfony 7

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.

11. FAQ: PHPUnit Teststrategien für APIs und DTO-Mapping

1API testen ohne echte HTTP-Verbindung?
Guzzle MockHandler oder Symfony MockHttpClient – vordefinierte Responses, Fehler und Timeouts simulieren. Fixture-Daten aus JSON-Dateien laden, nicht hartcodiert.
2Contract Test vs. Integration Test?
Contract Test: Struktur der API-Response (JSON-Schema). Integration Test: ob eigener Code korrekt damit umgeht. Unabhängige Schichten, beide notwendig.
3DTO mit Symfony Serializer testen?
Serializer direkt instanziieren (ohne Kernel), JSON-Fixture deserialisieren, alle Felder mit assertSame prüfen. Schnell, isoliert, kein Container nötig.
4API-Timeout in PHPUnit simulieren?
Guzzle MockHandler: ConnectException statt Response in die Queue legen. Einziger Weg, Timeouts reproduzierbar zu testen.
5Was ist Object Mother Pattern?
Statische Factory-Klasse (PaymentResponseMother) mit benannten Methoden (captured(), declined()). Verhindert Copy-Paste von Fixture-Daten zwischen Tests.
6Optionale DTO-Felder testen?
Separate Testmethode mit JSON ohne optionales Feld, Assertion auf null. Data Provider für mehrere fehlende Felder gleichzeitig.
7Welches JSON-Schema-Tool für PHP?
justinrainbow/json-schema ist die verbreitetste Option. Schema in JSON-Datei ablegen, im Test laden und gegen API-Response validieren.
8Unit oder Integration für API-Tests?
DTO-Tests ohne HTTP in Unit-Tests. HTTP-Stubbing in Integration-Tests. Contract Tests in eigenem Verzeichnis. Klare Trennung erleichtert selektive CI-Ausführung.
9Fixtures aktuell halten?
JSON-Dateien in tests/Fixtures/Http/. Bei API-Änderungen nur Fixture aktualisieren. Contract Tests schlagen automatisch an wenn API sich ändert.
10Warum readonly DTOs für Tests besser?
Garantieren Unveränderlichkeit nach Deserialisation. Erzwingen vollständige Fixture-Daten über Konstruktor oder Deserializer – keine partiellen Objekte mehr.