Blog
How to test Symfony APIs with Pest
Reliable Symfony API tests protect more than successful JSON responses. A strong Pest suite verifies validation, authorization, database behavior, error contracts, pagination, caching, external failures and performance boundaries.
Testing an API means more than sending requests
This is a reasonable first test for a new endpoint:
it('returns posts', function (): void {
$client = static::createClient();
$client->request('GET', '/api/en/posts');
self::assertResponseIsSuccessful();
});It proves that this request receives a successful HTTP status. It does not prove that drafts were excluded, that the response contains the requested translation, or that an ordinary user cannot publish an article. Those are separate behaviours, and each needs an assertion that would fail if the behaviour broke.
I would start with the endpoint’s contract: what the caller sends, what they may do, what comes back and what changes in the system. Then I would decide which of those promises needs an HTTP test and which can be checked more directly.
The examples below use Symfony 7.4, Pest 3 and PHPUnit 11 conventions. They describe an illustrative API, not the live GiSoft API. Routes, response formats and status choices belong to that example. Domain classes, builders and fixture helpers are abbreviated project-owned test infrastructure; their implementations and imports are omitted. These are patterns to adapt, not a suite that runs unchanged after copying it.
Choose the test level by the risk
Suite names vary between projects. Here, a unit test exercises an object without booting Symfony; an integration test uses the relevant real infrastructure; a functional API test sends a request through Symfony’s test client. That client runs the application in process. It does not exercise a real browser, reverse proxy or deployed network.
Behaviour at risk Cheapest reliable starting point
Business calculation Unit test
Doctrine query and mapping Integration test with a database
HTTP response contract Functional API test
Permission on a resource Policy/voter test + HTTP boundary
Critical user journey A small number of E2E testsA policy test can cover many ownership combinations cheaply. An HTTP test establishes that the route actually applies that policy. They protect different failure modes. There is little value in repeating every combination at every level.
Pest can coexist with existing PHPUnit classes. Convert a working test when doing so improves its maintenance, not merely to make the directory look uniform. For a project that deliberately has tests/Integration and tests/Functional, this is valid Pest 3 configuration in tests/Pest.php:
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
uses(KernelTestCase::class)->in('Integration');
uses(WebTestCase::class)->in('Functional');These directory names are illustrative; this repository organises tests by application namespace. Plain Pest tests use PHPUnit’s base TestCase unless configured otherwise. They do not need an empty uses() call or a kernel. Bind the HTTP examples below to WebTestCase, and repository examples to KernelTestCase; add the project’s fixture traits where required.
Make the HTTP contract visible
A helper is useful when it removes repeated JSON encoding while leaving the method, URL, headers and payload in sight. These two small functions are article helpers, not built-in Pest or Symfony APIs:
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Component\HttpFoundation\Response;
/**
* @param array<string, mixed>|null $payload
* @param array<string, string> $headers
*/
function requestJson(
KernelBrowser $client,
string $method,
string $uri,
?array $payload = null,
array $headers = [],
): void {
$client->request(
$method,
$uri,
server: array_replace([
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
], $headers),
content: $payload === null
? null
: json_encode($payload, JSON_THROW_ON_ERROR),
);
}
/** @return array<array-key, mixed> */
function responseJson(Response $response): array
{
$payload = json_decode(
(string) $response->getContent(),
true,
512,
JSON_THROW_ON_ERROR,
);
if (!is_array($payload)) {
throw new \UnexpectedValueException('Expected a JSON object or array.');
}
return $payload;
}Here, null means no request body; [] encodes a JSON array. To test an empty JSON object, send the raw body {} through $client->request(). Keep malformed bodies outside the encoder as well. responseJson() checks decoding and the top-level PHP type; it does not validate a response schema.
The list contract in this example is GET /api/{locale}/posts?page=1&limit=20, with items, pagination and locale in the response. Start from an isolated database, create the client before helpers that use the container, and persist one public article plus one draft:
it('returns a localised page of published posts', function (): void {
$client = static::createClient();
$this->createPublishedPost(
locale: 'en',
title: 'Testing Symfony APIs with Pest',
slug: 'testing-symfony-apis-with-pest',
);
$this->createDraftPost(locale: 'en', slug: 'unpublished-post');
requestJson($client, 'GET', '/api/en/posts?page=1&limit=20');
self::assertResponseStatusCodeSame(200);
$payload = responseJson($client->getResponse());
expect($payload)->toHaveKeys(['items', 'pagination', 'locale'])
->and($payload['locale'])->toBe('en')
->and($payload['items'])->toHaveCount(1)
->and($payload['pagination'])->toMatchArray([
'page' => 1, 'limit' => 20, 'total' => 1, 'pages' => 1,
])
->and($payload['items'][0])->toHaveKeys([
'id', 'title', 'slug', 'excerpt', 'publishedAt', 'author',
])
->and($payload['items'][0])->toMatchArray([
'title' => 'Testing Symfony APIs with Pest',
'slug' => 'testing-symfony-apis-with-pest',
]);
$author = $payload['items'][0]['author'];
expect(array_keys($author))->toEqualCanonicalizing(['id', 'displayName']);
expect($author['id'])->toBeInt();
expect($author['displayName'])->toBeString();
$mediaType = explode(';', (string) $client->getResponse()
->headers->get('Content-Type'))[0];
expect(trim($mediaType))->toBe('application/json');
});createPublishedPost() and createDraftPost() stand for deterministic fixture helpers that persist and flush their records. With only these two records present, the count and title assertions establish that the draft was excluded. The explicit author-field list protects the serialisation boundary: adding a private email or password hash makes this test fail.
Searching raw JSON for words such as password is a weak substitute. A field can be renamed, and a public article can legitimately contain that word. A search for a particular secret fixture value may add protection, but the known public object should have structural assertions first.
toHaveKeys() permits additional fields; an exact key comparison deliberately does not. Choose according to the contract. Comparing an entire raw JSON string can be unnecessarily sensitive to whitespace and key order, although exact matching is appropriate for a deliberately frozen response or reference fixture. Required values and types still need assertions: a list of keys alone will accept the wrong data.
Reject bad input and keep errors predictable
Separate invalid business input from a body that cannot be decoded. In this example, field validation returns 422, malformed JSON returns 400, and unsupported request media types return 415. These are explicit API choices, not universal Symfony defaults. Use the documented statuses of the application you are testing.
it('rejects invalid contact input without storing it', function (array $input): void {
$client = static::createClient();
requestJson($client, 'POST', '/api/en/contact', $input);
self::assertResponseStatusCodeSame(422);
$payload = responseJson($client->getResponse());
expect($payload['errors'])->toHaveKeys(['email', 'subject', 'message']);
expect($this->contactRequestCount())->toBe(0);
})->with([
[['email' => 'not-an-email', 'subject' => '', 'message' => '']],
[['email' => 'not-an-email']],
]);The dataset covers both empty and missing required fields. contactRequestCount() is an illustrative database assertion helper, and the contact table starts empty. This test checks the rejected response and the absence of a stored request. Where sending an email, calling a provider or dispatching a message is the critical risk, assert that specific forbidden effect too; every validation test need not inspect every dependency.
it('rejects malformed JSON', function (): void {
$client = static::createClient();
$client->request('POST', '/api/en/contact', server: [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
], content: '{"email":');
self::assertResponseStatusCodeSame(400);
});
it('rejects unsupported request content', function (): void {
$client = static::createClient();
$client->request('POST', '/api/en/contact', server: [
'CONTENT_TYPE' => 'text/plain',
'HTTP_ACCEPT' => 'application/json',
], content: 'plain text');
self::assertResponseStatusCodeSame(415);
});A missing Content-Type and an unacceptable Accept header are different cases. Content-Type describes the supplied body; Accept describes acceptable response representations. Test the endpoint’s documented handling, including 406 if content negotiation requires it. Do not assume every JSON endpoint enforces the same policy.
Public errors need a stable meaning without exposing exception classes, stack traces, SQL, file paths or credentials. An example error for an English article route is:
{
"error": {
"code": "post_not_found",
"message": "The requested article was not found.",
"details": []
}
}Keep internal diagnostic context in protected logs. Test the public code and safe message separately from that context:
it('returns the public not-found contract', function (): void {
$client = static::createClient();
requestJson($client, 'GET', '/api/en/posts/missing-post');
self::assertResponseStatusCodeSame(404);
$error = responseJson($client->getResponse())['error'];
expect($error)->toHaveKeys(['code', 'message', 'details'])
->and($error['code'])->toBe('post_not_found')
->and($error['message'])->toBe('The requested article was not found.')
->and($error['details'])->toBe([]);
});
it('rejects DELETE on the public read route', function (): void {
$client = static::createClient();
requestJson($client, 'DELETE', '/api/en/posts/example');
self::assertResponseStatusCodeSame(405);
});The second test assumes that the public path exists as a read route and that no DELETE route is defined. For a 405 contract, an assertion on the Allow header can also catch incorrect advertised methods. Check headers that affect clients, such as the JSON media type asserted in the list test, rather than snapshotting every header the framework emits.
Exercise both sides of a permission boundary
Authentication establishes the caller’s identity; authorisation decides whether an operation is permitted. Conventionally, 401 signals missing or invalid authentication credentials and 403 signals refusal to fulfil the request. A 403 alone does not prove that the caller was authenticated. Entry points, redirects and exception handling influence the actual response, so pin down the API contract first.
Our example admin API returns 401 to an anonymous JSON client:
it('rejects anonymous access to admin activity', function (): void {
$client = static::createClient();
requestJson($client, 'GET', '/api/admin/activity');
self::assertResponseStatusCodeSame(401);
});For publishing, test both a recognised user without permission and an authorised administrator. The article belongs to the test user, so ownership is controlled; the role is the variable under test:
it('enforces publication permission', function (
string $role,
int $status,
bool $published,
): void {
$client = static::createClient();
$user = $this->createUser(roles: [$role]);
$post = $this->createDraftPost(owner: $user);
$postId = $post->getId();
requestJson(
$client,
'POST',
sprintf('/api/admin/posts/%d/publish', $postId),
headers: $this->bearerHeadersFor($user),
);
self::assertResponseStatusCodeSame($status);
expect($this->reloadPost($postId)->isPublished())->toBe($published);
})->with([
['ROLE_USER', 403, false],
['ROLE_ADMIN', 204, true],
]);bearerHeadersFor() is an illustrative helper that issues a valid test credential for the given identity and returns its HTTP_AUTHORIZATION header. It must use the intended authentication mechanism. reloadPost() reads persisted state afresh after the request. Neither method is supplied by Pest. The example contract permits the administrator to publish and returns 204; other tenant, team or business-state restrictions require their own cases.
For a stateful firewall, Symfony’s loginUser() may be the appropriate test shortcut, using the matching security context. It does not authenticate a stateless firewall: send the required credential on each request instead. Keep separate tests for credential validation where a shortcut bypasses it. Do not disable a security check to make a denial or success test pass.
Password reset has a different boundary: the public response should not unnecessarily disclose whether an account exists. With an isolated user store containing only the seeded account, and a limiter that permits both requests:
it('keeps the password-reset response neutral', function (): void {
$client = static::createClient();
$this->createUser(email: 'existing@example.test');
requestJson($client, 'POST', '/api/en/password-reset', [
'email' => 'existing@example.test',
]);
self::assertResponseStatusCodeSame(202);
$existingMessage = responseJson($client->getResponse())['message'];
requestJson($client, 'POST', '/api/en/password-reset', [
'email' => 'missing@example.test',
]);
self::assertResponseStatusCodeSame(202);
expect(responseJson($client->getResponse())['message'])
->toBe($existingMessage);
});This example contract returns 202 with the same neutral message in both cases. Matching those observations protects against those particular disclosures; it does not rule out differences in timing, headers or other channels. Ordinary CI timing comparisons are too noisy to establish resistance to timing-based enumeration.
Localisation and pagination are data rules
A translated route can return 200 while selecting the wrong record. Give the fixture explicit slugs and compare the response with independent expected values, rather than deriving the expected slug from the same lookup being tested:
it('returns the requested translation', function (
string $locale,
string $slug,
string $title,
): void {
$client = static::createClient();
$this->createTranslatedPost([
'pl' => ['slug' => 'testowanie-api', 'title' => 'Testowanie API Symfony'],
'en' => ['slug' => 'testing-apis', 'title' => 'Testing Symfony APIs'],
'de' => ['slug' => 'apis-testen', 'title' => 'Symfony-APIs testen'],
'fr' => ['slug' => 'tester-api', 'title' => 'Tester les API Symfony'],
]);
requestJson($client, 'GET', sprintf('/api/%s/posts/%s', $locale, $slug));
self::assertResponseStatusCodeSame(200);
$payload = responseJson($client->getResponse());
expect($payload['locale'])->toBe($locale)
->and($payload['article']['slug'])->toBe($slug)
->and($payload['article']['title'])->toBe($title)
->and($payload['article'])->not->toHaveKey('translations');
})->with([
['pl', 'testowanie-api', 'Testowanie API Symfony'],
['en', 'testing-apis', 'Testing Symfony APIs'],
['de', 'apis-testen', 'Symfony-APIs testen'],
['fr', 'tester-api', 'Tester les API Symfony'],
]);createTranslatedPost() here persists one published article with precisely those translations. Fixture titles and identifiers remain the same in every language version of this article. Add cases for a missing translation and an unsupported locale according to the chosen policy: a 404, a documented fallback and a redirect are different contracts. A fallback test must check which language was actually returned. Check that other translations are not accidentally included in the public response.
Pagination needs a stable sort, including a tie-breaker for equal dates. The ordinary page-size case and invalid-input variations belong together:
it('returns the requested page', function (): void {
$client = static::createClient();
$this->createPublishedPosts(count: 40, locale: 'en');
requestJson($client, 'GET', '/api/en/posts?page=2&limit=10');
self::assertResponseStatusCodeSame(200);
$payload = responseJson($client->getResponse());
expect($payload['items'])->toHaveCount(10)
->and($payload['pagination'])->toMatchArray([
'page' => 2, 'limit' => 10, 'total' => 40, 'pages' => 4,
]);
});
it('rejects invalid pagination', function (string $query): void {
$client = static::createClient();
requestJson($client, 'GET', '/api/en/posts?' . $query);
self::assertResponseStatusCodeSame(400);
})->with([
'page=0&limit=20',
'page=-1&limit=20',
'page=1&limit=0',
'page=1&limit=10000',
]);This example accepts page >= 1 and 1 <= limit <= 100; out-of-range values produce 400. Another API may clamp a limit or use a validation response. Test that documented decision. The dataset is useful because only the input changes while the expected outcome stays the same.
The count test does not establish ordering. With fixed fixture dates, also compare expected IDs on adjacent pages, check that they do not overlap, and cover a page beyond the end. Queries involving translation or tag joins deserve a case that would reveal duplicated articles or inflated totals.
Test queries with a database and decisions with objects
If the risk lies in DQL, joins or Doctrine mapping, mocking QueryBuilder misses it. This illustrative anti-example only configures a fluent mock:
$queryBuilder = $this->createMock(\Doctrine\ORM\QueryBuilder::class);
$queryBuilder->method('select')->willReturnSelf();
$queryBuilder->method('join')->willReturnSelf();
$queryBuilder->method('where')->willReturnSelf();Such a double can help test a narrow collaboration, but it cannot show that the query selects the right records. Prioritise integration tests for complex or important repository queries, using an isolated database with a compatible engine and schema:
it('finds only published posts in the requested locale', function (): void {
static::bootKernel();
$published = $this->createPublishedPost(locale: 'en', slug: 'published-post');
$this->createDraftPost(locale: 'en', slug: 'draft-post');
$repository = $this->postReadRepository();
$result = $repository->findPublishedBySlug('en', 'published-post');
expect($result)->not->toBeNull()
->and($result->id)->toBe($published->getId())
->and($repository->findPublishedBySlug('en', 'draft-post'))->toBeNull()
->and($repository->findPublishedBySlug('de', 'published-post'))->toBeNull();
});postReadRepository() retrieves the real Doctrine implementation; fixture helpers flush before querying. The example repository returns an article read model or null, without a language fallback. Clearing managed state where appropriate avoids mistaking an already loaded object for proof of a fresh database read. Not every trivial repository method needs its own integration test.
An application service that maps that read model to an output DTO can be tested without HTTP or a kernel:
it('maps a published article to a localised DTO', function (): void {
$article = ArticleBuilder::new()
->withEnglishTranslation(
title: 'Testing Symfony APIs with Pest',
slug: 'testing-symfony-apis-with-pest',
)->build();
$repository = $this->createMock(PostReadRepositoryInterface::class);
$repository->expects($this->once())
->method('findPublishedBySlug')
->with('en', 'testing-symfony-apis-with-pest')
->willReturn($article);
$result = (new GetPublishedPostService($repository))
->get('en', 'testing-symfony-apis-with-pest');
expect($result)->not->toBeNull()
->and($result->title)->toBe('Testing Symfony APIs with Pest');
});ArticleBuilder, the repository interface and GetPublishedPostService are illustrative domain types. The builder must return the type declared by findPublishedBySlug(), and the expected DTO must match get()’s contract. This example uses PHPUnit’s existing mocks. Mockery is not installed in this repository; there is no reason to add it just to express this expectation.
Separate accepting work from doing the work
For POST /api/en/contact, a useful HTTP test proves that the request was stored and the intended confirmation message was dispatched. It need not run a worker or send real email. This excerpt assumes the test environment already routes SendContactConfirmation to an in-memory transport named async:
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;
it('stores a contact request and dispatches its confirmation', function (): void {
$client = static::createClient();
$transport = static::getContainer()->get('messenger.transport.async');
self::assertInstanceOf(InMemoryTransport::class, $transport);
$transport->reset();
requestJson($client, 'POST', '/api/en/contact', [
'email' => 'client@example.test',
'subject' => 'API integration',
'message' => 'Please send the integration requirements.',
]);
self::assertResponseStatusCodeSame(201);
$contact = $this->findContactRequestByEmail('client@example.test');
expect($contact)->not->toBeNull();
$sent = $transport->getSent();
expect($sent)->toHaveCount(1);
$message = $sent[0]->getMessage();
expect($message)->toBeInstanceOf(SendContactConfirmation::class)
->and($message->contactRequestId)->toBe($contact->getId());
});InMemoryTransport::getSent() is a Symfony API and returns envelopes. The message type and public contactRequestId property belong to the example application; findContactRequestByEmail() reads the test database. Retrieve the transport after creating the client, start with empty storage and keep this test to one request. Kernel reboots and service resets matter when inspecting transport state across several requests.
Methods such as messengerTransport() or queue()->assertCount() are not generic Symfony/Pest APIs. If a project provides equivalents, name that dependency explicitly. An in-memory dispatch assertion also does not exercise a real broker or necessarily its serialisation path; cover those separately where they matter.
The handler gets its own test:
it('sends the confirmation and records the result', function (): void {
$contact = ContactRequestBuilder::new()->build();
$mailer = new FakeMailSender();
$reports = new InMemoryEmailReportRepository();
$handler = $this->createHandler(
contact: $contact,
mailer: $mailer,
reports: $reports,
);
$handler(new SendContactConfirmation(
contactRequestId: $contact->getId(),
));
expect($mailer->sent())->toHaveCount(1)
->and($reports->all())->toHaveCount(1);
});These fakes and createHandler() are illustrative test infrastructure. The helper must wire a contact repository containing the supplied contact, whose builder assigns a stable ID, and pass through the exact mailer and report repository shown. Inspecting those same objects makes the side-effect assertions meaningful. Add redelivery cases at this boundary when duplicate handling could send another email or repeat a business operation.
Distinguish failure handling, rollback and idempotency
A failing repository double is useful for an application-service failure path:
it('reports an order persistence failure', function (): void {
$repository = new FailingOrderRepository();
$service = $this->createOrderService(orders: $repository);
expect(fn () => $service->create(
CreateOrderDtoBuilder::valid()->build(),
))->toThrow(OrderPersistenceFailed::class);
expect($repository->savedOrders())->toBeEmpty();
});FailingOrderRepository and the service factory are illustrative. If the fake throws before retaining an order, the empty collection assertion says nothing about Doctrine rollback. A real transaction test must exercise the application’s transaction boundary against the test database: force a failure after a real write, then verify through a fresh read that no partial order or associated outbox record remains. An outer test transaction that rolls everything back at teardown can hide a missing application rollback; make the assertion before cleanup and use an isolation mode suitable for testing real commits.
Idempotency needs a separate test. Here, the example API replays the original 201 response for the same caller, idempotency key and logical payload:
it('replays an order without creating a second one', function (): void {
$client = static::createClient();
$customer = $this->createCustomer();
$product = $this->createProduct();
$payload = [
'customerId' => $customer->getId(),
'items' => [['productId' => $product->getId(), 'quantity' => 2]],
];
$headers = array_replace($this->bearerHeadersFor($customer), [
'HTTP_IDEMPOTENCY_KEY' => 'order-request-123',
]);
requestJson($client, 'POST', '/api/en/orders', $payload, $headers);
self::assertResponseStatusCodeSame(201);
$first = responseJson($client->getResponse());
expect($first['id'])->toBeInt();
requestJson($client, 'POST', '/api/en/orders', $payload, $headers);
self::assertResponseStatusCodeSame(201);
expect(responseJson($client->getResponse())['id'])->toBe($first['id'])
->and($this->orderCount())->toBe(1);
});The customer and product helpers persist valid fixtures; the customer is an authenticated principal in this example, and the order store starts empty. The test checks a successful response, the same order identity and one persisted order. Comparing only the two statuses would also accept two failures. If the documented replay status is 200 instead, assert that explicitly. Reusing a key with a different payload needs its own conflict-policy test.
This is a sequential retry test. It does not establish safety under concurrent requests. Where concurrent duplication matters, test competing requests against the real mechanism, such as a unique constraint and atomic claim, and account for provider-supported idempotency when an external operation is involved.
Give external failures and cache behaviour explicit contracts
A GeoIP outage might leave an optional country unknown while allowing an activity record to be stored. That is an example product decision, not a rule that every provider failure should be ignored:
final class FailingGeoIpResolver implements GeoIpResolverInterface
{
public function resolve(string $ip): GeoIpResult
{
throw new GeoIpUnavailable();
}
}
it('records activity without a country when GeoIP is unavailable', function (): void {
$client = static::createClient();
static::getContainer()->set(
GeoIpResolverInterface::class,
new FailingGeoIpResolver(),
);
requestJson($client, 'GET', '/api/en/posts');
self::assertResponseStatusCodeSame(200);
$activities = $this->recordedActivities();
expect($activities)->toHaveCount(1)
->and($activities[0]->country())->toBeNull();
});The resolver interface, result, exception and recordedActivities() helper are illustrative project types. This test starts with an empty activity store and assumes the request records one activity synchronously. The test container must allow replacement of the resolver before its consumer is instantiated. Replace it after createClient() boots the kernel; restore isolation between tests. Normal automated tests should use controlled provider responses, with deliberate provider contract checks kept separate.
For cache, decide what is observable. If avoiding repeated repository reads is an explicit requirement, a call-count expectation is justified:
it('reuses cached settings for the same locale', function (): void {
$settings = new PublicSettingsDto(
companyName: 'Example',
slogan: 'Technical articles',
phone: '+48 000 000 000',
);
$repository = $this->createMock(PublicSettingsRepositoryInterface::class);
$repository->expects($this->once())->method('getForLocale')
->with('en')->willReturn($settings);
$service = $this->createSettingsService(
repository: $repository,
cache: new InMemoryApplicationCache(),
);
expect($service->getForLocale('en'))->toEqual($settings)
->and($service->getForLocale('en'))->toEqual($settings);
});The service factory and in-memory cache are illustrative. This test proves reuse within that cache implementation and lifetime; it does not prove a Redis adapter works or that invalidation is correct. A separate integration case should warm English and German settings, update the English slogan through the admin write path, then assert that English is fresh and German remains available without a reload if that is the project’s invalidation rule. A broader invalidation policy would need different expectations.
Put operational limits under controlled conditions
Rate-limit tests need fresh, isolated limiter storage and a controlled time source. The state must survive the requests inside one test, including client kernel reboots, but be cleared or uniquely scoped between tests and parallel workers. In the following example setup, the policy allows five contact submissions per IP per window, all six attempts stay in that window, and no other protection rejects the valid payload:
it('limits repeated contact submissions', function (): void {
$client = static::createClient();
$payload = [
'email' => 'client@example.test',
'subject' => 'API integration',
'message' => 'Please send the integration requirements.',
];
for ($attempt = 1; $attempt <= 6; ++$attempt) {
requestJson($client, 'POST', '/api/en/contact', $payload, [
'REMOTE_ADDR' => '192.0.2.10',
]);
self::assertResponseStatusCodeSame($attempt <= 5 ? 201 : 429);
}
expect($this->contactRequestCount())->toBe(5);
});Five is an illustrative threshold. The stored-record count also checks that the rejected attempt did not persist another request. Use the limiter implementation’s supported test clock to check expiry; do not add real sleep() calls or assume any clock double automatically controls the limiter’s internal time source.
A query budget is useful for an endpoint at risk of N+1 regressions:
it('bounds application queries for a list page', function (int $count): void {
$client = static::createClient();
$this->createPublishedPosts(count: $count, locale: 'en');
$collector = $this->startApplicationQueryCollection();
requestJson($client, 'GET', '/api/en/posts?page=1&limit=50');
self::assertResponseStatusCodeSame(200);
expect(responseJson($client->getResponse())['items'])
->toHaveCount(min($count, 50));
expect($collector->queryCount())->toBeLessThanOrEqual(4);
})->with([5, 50, 500]);startApplicationQueryCollection() is an illustrative collector helper, not a Symfony method. It must observe the Doctrine connection actually used by the request, including after any reboot. Its measurement excludes fixtures, container boot, test authentication setup and unrelated framework queries. The bound of four is a project-specific example. Testing different source counts helps expose growth, but a small query count does not establish low latency or efficient query plans.
A response-size budget catches a different class of regression:
it('bounds the public list response size', function (): void {
$client = static::createClient();
$this->createPublishedPosts(count: 100, locale: 'en');
requestJson($client, 'GET', '/api/en/posts?page=1&limit=25');
self::assertResponseStatusCodeSame(200);
expect(responseJson($client->getResponse())['items'])->toHaveCount(25);
expect(strlen((string) $client->getResponse()->getContent()))
->toBeLessThan(300_000);
});The 300_000-byte limit applies to this example’s uncompressed body and representative deterministic fixtures. It is not a general API limit. It can flag full article bodies, all translations or an unexpectedly expanded entity graph. Keep the structural field checks as well: a small response can still disclose a secret.
Keep data, time and isolation predictable
Use fixed emails such as client@example.test, explicit slugs, locales, prices, operation IDs and external references wherever they influence assertions. Random data can be useful for broader exploration, but failures must be reproducible. A small builder should reveal the state that matters:
$post = PostBuilder::new()
->published()
->translated(
locale: 'en',
title: 'Testing Symfony APIs with Pest',
slug: 'testing-symfony-apis-with-pest',
)
->build();This PostBuilder is illustrative, and build() creates an object; persistence is a separate fixture step. Do not hide publication, ownership or tenant assignment in defaults when those values determine the outcome.
Use the clock abstraction already adopted by the project. Symfony’s MockClock can stand in for a compatible clock dependency, including Psr\Clock\ClockInterface, without introducing a custom FrozenClock:
use Symfony\Component\Clock\MockClock;
$clock = new MockClock('2026-09-17T09:00:00+00:00');
$clock->modify('+1 hour');
expect($clock->now()->format('c'))->toBe('2026-09-17T10:00:00+00:00');This fragment demonstrates controlled time only. Inject that clock into the service under test before checking publication dates, token expiry or cache TTL. Advancing an unrelated local clock will not affect those services.
Database isolation can use rollback per test, a reset, schema recreation or controlled fixture reloads. Rollback is often fast but does not isolate writes on independent connections or external systems. Recreating or resetting a database costs more but can exercise committed behaviour. Choose deliberately, isolate parallel workers, and reset queues, caches and limiter state too. Use synthetic data, never production data, and make each test independent of execution order.
Add static and schema checks for their own responsibilities
Pest architecture tests can enforce selected dependency rules. This repository includes Pest 3’s architecture plugin; these examples use its supported syntax:
arch('API controllers do not access Doctrine directly')
->expect('App\Api\Controller')
->not->toUse('Doctrine\ORM\EntityManagerInterface');
arch('core does not depend on HTTP delivery')
->expect('App\Core')
->not->toUse('Symfony\Component\HttpFoundation');The namespaces and restrictions describe possible project rules, not requirements imposed by Symfony or proof that this repository currently satisfies them. Adopt only agreed rules and use the project’s actual namespaces. No additional plugin is needed in this repository.
PHPStan should analyse test helpers as well as application code. The requestJson() annotations above describe its inputs; responseJson() honestly returns a decoded array of still-unvalidated values. A large PHPDoc response shape does not validate JSON at runtime. Narrow values with checks, or use an existing typed response DTO with validation, rather than asserting a type the helper has not established. Static analysis complements runtime contract tests.
If the API already has an OpenAPI document or equivalent schema, validate representative responses against it using existing tooling. That can catch missing fields, wrong types and documented constraints. It cannot establish authorisation, correct database selection or the absence of forbidden side effects. The availability of a documentation bundle alone does not prove every route has a usable schema.
Backwards compatibility is also observable. For example:
{
"id": 42,
"status": "published",
"publishedAt": "2026-09-17T09:00:00+00:00"
}Changing publishedAt from this ISO 8601 string to a Unix timestamp changes the contract even if both represent the same instant. Retain a focused compatibility assertion for formats that clients depend on.
Turn one endpoint into a focused test plan
For POST /api/{locale}/contact, I would allocate tests by the failure each one should expose:
Contact behaviour Test responsibility
Normalisation and decisions Unit: input becomes intended data
Persistence and constraints Integration: real database writes
Method, JSON, validation API: accepted/rejected request
Access and abuse controls API: configured security boundary
Confirmation dispatch API: correct message and contact ID
Confirmation handling Handler: mail and report behaviour
Public response API: status, fields, safe errorsThis is a division of responsibility, not a demand to duplicate each row in three suites. Start with the accepted request, then choose failure cases from the endpoint’s actual risks. The examples above provide those building blocks without making the contact test teach all of Doctrine, Security or Messenger.
CI should provide quick feedback from syntax and static checks, then cover unit, integration and functional API behaviour, with selected architecture, compatibility or E2E checks as needed. Separate jobs may run in parallel; the exact order and suite paths belong to the repository. This project exposes these Composer scripts through Docker:
docker compose exec -T web composer analyse
docker compose exec -T web composer testThe first command runs the configured static analysis; the second runs the project’s test suite. Select existing test files or groups for focused work; do not assume tests/Unit exists just because it appeared in an illustrative layout.
When using an AI assistant, give it the current contract and security rules before asking for tests. Review whether its assertions actually protect pagination, side effects and both permitted and rejected operations. Run the relevant tests and static analysis; syntactically plausible code is not verification.
A useful API test makes a specific promise checkable. Choose the cheapest level that can genuinely break when that promise is violated, and keep enough HTTP coverage to prove the endpoint connects those behaviours correctly.
