PHPUnit Test Strategies for APIs and DTO Mapping
AI generated
@test
assert
PHPUnit · API Testing · DTO Mapping · Contract Tests
PHPUnit Test Strategies for APIs and DTO Mapping
from contract tests to deserialization validation

REST APIs and DTO mapping are two of the most common sources of bugs in PHP projects, and two of the hardest areas to test when the strategy is missing. Contract tests, schema validation and clean HTTP stubbing patterns make API tests reproducible, fast and meaningful.

14 min read Contract Tests · DTO · Symfony Serializer · HTTP Stubbing PHPUnit 11 · PHP 8.4 · Symfony 7

1. The challenge of testing APIs

REST APIs come with a specific kind of test complexity: they have an outer boundary (HTTP), a data format (JSON/XML), a mapping step (DTO deserialization) and business logic on the inside. Each of these layers can introduce its own bugs. A test strategy that only checks business logic misses the fact that parsing a slightly changed API response alone can bring down the entire stack.

The fundamental problem: using a real HTTP connection in a test makes tests slow, fragile and dependent on the network. At the same time, fully mocking the HTTP client means you no longer test whether your own code handles the real API response correctly. The solution lies in a layered strategy: HTTP stubbing for unit tests, recorded responses for integration tests, contract tests for API compatibility.

In PHP projects using Symfony, Laravel or Slim, there are established patterns for this. Guzzle's MockHandler lets you simulate HTTP responses in tests without ever leaving the network layer. Symfony's HttpClientInterface can be replaced with a MockHttpClient. And for DTO mapping, PHPUnit offers precise assertion methods that check exactly whether the deserialization result matches the expected object.

2. Contract tests: securing API contracts

A contract test ensures that an API response contains the expected structure and types, regardless of whether the business logic handles it correctly. Contract tests are especially important for external APIs that can change without your own control: payment providers, shipping services, ERP systems. They do not check whether your own code is correct, but whether the external partner is honoring its contract.

In PHP, the simplest way to implement contract tests is as PHPUnit tests that run against a recorded or mocked response and use JSON Schema or Symfony Validator to check that all required fields are present and correctly typed. When an external service changes its API version, the contract test fails immediately, long before the error becomes visible in production. For microservice architectures, Pact is well suited as a dedicated contract testing framework that manages consumer-driven contracts between services.


<?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 with MockHandler

HTTP stubbing is the most important building block for fast, deterministic API tests. Instead of establishing a real HTTP connection, a MockHandler returns predefined responses. With Guzzle this happens via MockHandler and HandlerStack. With Symfony's HTTP client, the MockHttpClient is configured with a list of MockResponse objects. Both approaches let you simulate HTTP errors, timeouts and unexpected status codes, scenarios that would be hard to reproduce with a real API.

In the test, the recorded response should be loaded from a fixture file, not hardcoded inline in the test code. That makes tests more readable, allows the fixtures to be updated when the API changes, and separates test logic from test data. A recommended convention: fixture files live in tests/Fixtures/Http/ with descriptive names such as payment-gateway-capture-success.json and payment-gateway-capture-declined.json.

4. Testing DTO mapping and deserialization

DTO mapping is the step where a JSON payload is converted into a PHP object. That sounds trivial but is a common source of bugs: type mismatches (string instead of integer), optional fields that may be missing, nested objects, date values delivered as strings. In the test, every one of these scenarios must be covered explicitly, with fixture data that precisely simulates the inputs the real API can deliver.

With Symfony Serializer, deserialization happens via $serializer->deserialize($json, PaymentResponse::class, 'json'). In the test, the serializer is instantiated directly, without a Symfony kernel, without a container. That keeps the test fast and isolated. The assertions do not just check whether the object was created, but every single field value: type, value, nested objects, nullable fields.


<?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 validation in tests

Schema validation checks whether a JSON response has the expected format before deserialization is even attempted. This is especially helpful when you do not control which fields an external API returns. With justinrainbow/json-schema, you can instantiate a JSON schema validator in PHPUnit that loads the schema from a file and validates it against the API response. If validation fails, the error is precise: which field is missing, which type is wrong.

Schema tests are also excellent as regression tests. When an external API introduces a new field version or turns an optional field into a required one, the schema test immediately shows where the incompatibility lies. Unlike a contract test at the object level, the schema test validates the raw JSON structure, independent of how the PHP code handles it. That makes it the earliest possible safety net in the API integration chain.

6. Deserialization edge cases and error paths

Deserialization edge cases are the test scenarios most often missing in practice: what happens if an expected field is completely absent? What if a number is delivered as a string? What if a nested object is replaced by an empty object? These scenarios occur regularly in production, whether through API version changes, special cases in backend systems, or network errors that deliver a partial response.

In the PHPUnit test, these edge cases are covered as separate test methods or as a data provider. For each edge case there is a dedicated fixture file or JSON string that represents exactly the extreme scenario. The assertion then checks either that the DTO is created correctly (with defaults for optional fields), or that a specific exception is thrown. The latter must be verified with expectException() and a precise exception message assertion.


<?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 and PHP 8.4

PHP 8.4 and modern readonly properties change how DTOs are created in tests. A readonly DTO cannot be modified after the fact, which is exactly the guarantee you want for immutable API responses. In the test, this means: DTOs are either created via the constructor with concrete values, or via the deserializer, which internally calls the constructor. There is no more subsequent setter call.

For tests, this has an important consequence: fixture objects can no longer be created by partial modification. Instead, you need either named arguments in the constructor (which PHP 8.0+ allows) or factory methods in the test namespace that create a complete test DTO with sensible defaults. The Object Mother pattern is a great fit here: a static factory class PaymentResponseMother with methods such as captured(), declined(), pending() that return complete test DTOs.

8. Testing different API response variants

A well-tested API integration covers all response variants: success (200), validation error (422), authentication error (401), service unavailable (503) and timeout. For each of these variants there is a dedicated fixture and a dedicated test. The test checks not just whether no exception is thrown, but also what behavior your own code exhibits: is a retry performed? Is a domain exception thrown? Is a fallback value returned?

With Guzzle's MockHandler, timeout scenarios can also be simulated by placing a ConnectException object into the handler queue instead of a Response. This is the only way to trigger a timeout reproducibly in a test. For retry logic, it is important to test how many attempts occur, whether the wait time between attempts is correct, and whether, after all attempts are exhausted, the right error is propagated.

9. Test strategy comparison for API projects

Depending on dependency and risk, different test approaches suit API integrations. The following table gives an overview of the most important test levels and when each approach makes sense.

Test Type Tool Checks When to use
DTO unit test PHPUnit + Serializer Field mapping, types, edge cases Always, for every DTO class
HTTP stubbing Guzzle MockHandler Client logic, error handling For all API client classes
Contract test PHPUnit + JSON Schema API response structure For external APIs without your own control
Integration test Symfony WebTestCase HTTP → DTO → business logic For critical end-to-end flows
Live API test PHPUnit + real connection Real API compatibility Only in dedicated smoke test pipelines

The test strategy pyramid applies to API tests as well: many fast DTO unit tests form the base, above that come HTTP stubbing tests for client logic, then a small number of contract tests and integration tests. Live API tests are not continuous CI tests but run in dedicated monitoring pipelines or staging deployments.

Mironsoft

PHP API development, test strategies and DTO architecture

API integrations that stay stable even when things change?

We build API integrations with a complete test strategy: contract tests, DTO mapping tests and HTTP stubbing patterns that give your API layer an early warning even when external changes happen.

API test strategy

From DTO unit tests to contract tests, a complete test strategy for your API integrations

DTO architecture

Readonly DTOs, Object Mother pattern and fixture management for maintainable API tests

CI integration

Integrating contract tests and schema validation into CI pipelines, early warning on API changes

10. Summary

A complete test strategy for APIs and DTO mapping in PHP consists of several layers building on one another. DTO unit tests check in isolation whether deserialization produces correct PHP objects. HTTP stubbing with MockHandler makes API client tests fast and deterministic. Contract tests with JSON Schema secure external API contracts. Integration tests check the overall flow from HTTP request through to the business logic response.

Edge cases, missing fields, type deviations, empty objects, are the most common sources of production errors in API integrations. PHP 8.4 with readonly DTOs and named arguments makes test fixtures more precise and testable. The Object Mother pattern and fixture files in a dedicated directory ensure maintainable test data that can be updated independently of the test code.

API Test Strategies: The Essentials at a Glance

HTTP stubbing

Guzzle MockHandler / Symfony MockHttpClient, makes API tests network-independent, deterministic and fast. Load fixtures from JSON files, not hardcoded.

Contract tests

JSON Schema validation against API responses, early warning for external API changes, independent of your own business logic.

DTO edge cases

Every optional field, every null value, every type deviation, covered as its own test or data provider. Edge cases are the most common source of bugs.

Object Mother pattern

Static factory classes for test DTOs with named states (captured, declined, pending). No copy-pasting fixture data between tests.

11. FAQ: PHPUnit Test Strategies for APIs and DTO Mapping

1Test APIs without a real HTTP connection?
Guzzle MockHandler or Symfony MockHttpClient, simulate predefined responses, errors and timeouts. Load fixture data from JSON files, not hardcoded.
2Contract test vs. integration test?
Contract test: structure of the API response (JSON Schema). Integration test: whether your own code handles it correctly. Independent layers, both necessary.
3Test DTOs with Symfony Serializer?
Instantiate the serializer directly (without a kernel), deserialize a JSON fixture, check all fields with assertSame. Fast, isolated, no container needed.
4Simulate an API timeout in PHPUnit?
Guzzle MockHandler: place a ConnectException in the queue instead of a Response. The only way to test timeouts reproducibly.
5What is the Object Mother pattern?
A static factory class (PaymentResponseMother) with named methods (captured(), declined()). Prevents copy-pasting fixture data between tests.
6Test optional DTO fields?
Separate test method with JSON that omits the optional field, assertion on null. Use a data provider for several missing fields at once.
7Which JSON Schema tool for PHP?
justinrainbow/json-schema is the most common option. Store the schema in a JSON file, load it in the test and validate against the API response.
8Unit or integration for API tests?
DTO tests without HTTP in unit tests. HTTP stubbing in integration tests. Contract tests in their own directory. A clear separation makes selective CI execution easier.
9Keep fixtures up to date?
JSON files in tests/Fixtures/Http/. When the API changes, update only the fixture. Contract tests fail automatically when the API changes.
10Why are readonly DTOs better for tests?
They guarantee immutability after deserialization. They enforce complete fixture data via the constructor or the deserializer, no more partial objects.