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.
Table of Contents
- 1. The challenge of testing APIs
- 2. Contract tests: securing API contracts
- 3. HTTP stubbing with MockHandler
- 4. Testing DTO mapping and deserialization
- 5. Schema validation in tests
- 6. Deserialization edge cases and error paths
- 7. Readonly DTOs and PHP 8.4
- 8. Testing different API response variants
- 9. Test strategy comparison for API projects
- 10. Summary
- 11. FAQ
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.