Blog
Comment tester les API Symfony avec Pest
Des tests fiables pour une API Symfony protègent plus qu’une réponse JSON réussie. Une suite Pest vérifie validation, autorisations, base de données, contrats d’erreur, pagination, cache, défaillances externes et limites de performance.
Tester une API ne se résume pas à envoyer des requêtes
Pour un nouvel endpoint, ce test constitue un point de départ raisonnable :
it('renvoie les articles', function (): void {
$client = static::createClient();
$client->request('GET', '/api/en/posts');
self::assertResponseIsSuccessful();
});Il confirme qu’une requête donnée reçoit un statut HTTP de succès. Il ne vérifie ni l’exclusion des brouillons, ni le choix de la bonne traduction, ni l’interdiction de publier pour un utilisateur ordinaire. Chacun de ces comportements demande une assertion qui échouerait s’il était modifié par erreur.
Je commencerais par préciser le contrat de l’endpoint : ce que le client envoie, ce qu’il a le droit de faire, ce qu’il reçoit et ce que la requête change dans le système. On peut ensuite décider quelles garanties exigent un test HTTP et lesquelles se vérifient plus directement.
Les exemples suivent les conventions de Symfony 7.4, Pest 3 et PHPUnit 11. Ils décrivent une API fictive, pas l’API GiSoft en production. Les routes, formats de réponse et choix de statuts appartiennent à cet exemple. Les classes métier, builders et fonctions de préparation des données représentent une infrastructure de test propre au projet ; leurs implémentations et certains imports sont omis. Ces extraits sont à adapter : ils ne forment pas une suite exécutable telle quelle.
Choisir le niveau de test en fonction du risque
Les noms des suites varient selon les projets. Ici, un test unitaire exerce un objet sans démarrer Symfony. Un test d’intégration utilise l’infrastructure réelle concernée. Un test fonctionnel d’API passe par le client de test Symfony. Ce dernier exécute l’application dans le même processus : il ne teste ni navigateur réel, ni reverse proxy, ni réseau de l’environnement déployé.
Comportement à vérifier Premier niveau fiable et peu coûteux
Calcul métier Unitaire
Requête et mapping Intégration avec base de données
Contrat de réponse HTTP Fonctionnel API
Permission sur un objet Règle/voter + accès par HTTP
Parcours utilisateur clé Quelques tests E2EUn test de règle d’autorisation peut couvrir de nombreux cas de propriété à faible coût. Le test HTTP vérifie que la route applique effectivement cette règle. Ils détectent des erreurs différentes ; il n’est pas nécessaire de reproduire toutes les combinaisons à chaque niveau.
Pest peut cohabiter avec les classes PHPUnit existantes. Convertissez un test lorsque cela facilite sa maintenance, pas uniquement pour uniformiser son écriture. Dans un projet qui possède volontairement tests/Integration et tests/Functional, cette configuration de tests/Pest.php est valide avec Pest 3 :
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
uses(KernelTestCase::class)->in('Integration');
uses(WebTestCase::class)->in('Functional');Ces répertoires sont donnés à titre d’exemple ; ce dépôt classe ses tests selon les espaces de noms de l’application. Sans configuration particulière, Pest utilise la classe TestCase de PHPUnit. Les tests d’objets simples n’ont besoin ni d’un appel vide à uses() ni du kernel. Les tests HTTP ci-dessous supposent un rattachement à WebTestCase, ceux des repositories à KernelTestCase, avec les traits de préparation des données du projet lorsqu’ils sont nécessaires.
Garder le contrat HTTP lisible dans le test
Une fonction utilitaire peut éviter de répéter l’encodage JSON tout en laissant visibles la méthode, l’URL, les en-têtes et le contenu. Les deux fonctions suivantes sont proposées pour l’article ; elles ne font pas partie des API natives de Pest ou de Symfony :
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;
}Ici, null signifie que la requête n’a pas de corps, tandis que [] est encodé en tableau JSON. Pour tester un objet JSON vide, envoyez directement {} avec $client->request(). Un JSON volontairement mal formé doit lui aussi contourner l’encodeur. responseJson() contrôle le décodage et le type PHP de premier niveau, pas le schéma de la réponse.
Le contrat de liste retenu est GET /api/{locale}/posts?page=1&limit=20, avec les champs items, pagination et locale. Partez d’une base isolée, créez le client avant les fonctions qui utilisent le conteneur, puis enregistrez un article publié et un brouillon :
it('renvoie une page localisée des articles publiés', 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() et createDraftPost() désignent des fonctions de préparation déterministes qui enregistrent les données et exécutent flush(). Avec ces deux seuls articles, les assertions sur le nombre de résultats et le titre prouvent que le brouillon est exclu. La liste explicite des champs de l’auteur protège la sérialisation : l’ajout d’une adresse privée ou d’un hash de mot de passe fait échouer le test.
Chercher le mot password dans le JSON brut est un contrôle beaucoup plus faible. Le champ pourrait changer de nom, et un article public peut parfaitement contenir ce mot. Rechercher une valeur secrète précise issue des fixtures peut compléter les contrôles, mais un objet public connu mérite d’abord des assertions structurelles.
toHaveKeys() autorise des champs supplémentaires ; une comparaison exacte des clés les interdit délibérément. Le choix dépend du contrat. Comparer toute la chaîne JSON peut rendre le test inutilement sensible aux espaces ou à l’ordre des clés. Cela reste pertinent pour une réponse volontairement figée ou un fichier de référence. Les clés seules ne suffisent pas non plus : les valeurs et leurs types doivent être vérifiés.
Refuser les entrées invalides et stabiliser les erreurs
Distinguez une donnée métier invalide d’un corps impossible à décoder. Dans notre exemple, la validation des champs renvoie 422, le JSON mal formé 400 et un type de contenu non pris en charge 415. Ce sont des choix explicites de cette API, pas des règles universelles de Symfony. Les tests doivent suivre les statuts documentés par l’application.
it('refuse les données de contact invalides sans les enregistrer', 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']],
]);Le jeu de données couvre les champs obligatoires vides et absents. contactRequestCount() est une fonction illustrative de lecture en base ; la table des demandes est vide au départ. Le test contrôle le refus et l’absence d’enregistrement. Si le risque porte sur l’envoi d’un e-mail, un appel fournisseur ou l’émission d’un message, vérifiez l’absence de cet effet précis. Chaque test de validation n’a pas à observer toutes les dépendances.
it('refuse le JSON mal formé', 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('refuse un type de contenu non pris en charge', 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);
});Un Content-Type absent et un en-tête Accept impossible à satisfaire sont deux cas distincts. Le premier décrit le corps envoyé ; le second indique les représentations de réponse acceptées. Vérifiez le comportement documenté, y compris 406 si la négociation de contenu le prévoit. Tous les endpoints JSON n’appliquent pas la même politique.
Une erreur publique doit garder un sens stable sans exposer de classes d’exception, de traces d’appels, de SQL, de chemins de fichiers ou d’identifiants secrets. Pour une route d’article en anglais, elle pourrait prendre cette forme :
{
"error": {
"code": "post_not_found",
"message": "The requested article was not found.",
"details": []
}
}Le contexte de diagnostic interne reste dans des journaux protégés. Le code public et le message sans information sensible se testent séparément :
it('renvoie le contrat public pour un article absent', 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('refuse DELETE sur la route publique de lecture', function (): void {
$client = static::createClient();
requestJson($client, 'DELETE', '/api/en/posts/example');
self::assertResponseStatusCodeSame(405);
});Le second test suppose que ce chemin public existe pour la lecture et qu’aucune route DELETE n’y est définie. Pour un contrat 405, une assertion sur Allow peut aussi détecter une liste incorrecte de méthodes disponibles. Vérifiez les en-têtes utiles au client, comme le type JSON dans le test de liste, plutôt que de figer tous ceux produits par le framework.
Tester les opérations autorisées comme les refus
L’authentification établit l’identité de l’appelant. L’autorisation détermine s’il peut effectuer l’opération. Selon la sémantique HTTP habituelle, 401 signale des informations d’authentification absentes ou invalides, tandis que 403 exprime le refus de satisfaire la requête. Un 403 ne prouve pas à lui seul que l’appelant est authentifié. Le point d’entrée d’authentification, les redirections et la gestion des exceptions influencent la réponse concrète : définissez d’abord le contrat de l’API.
L’API d’administration de cet exemple renvoie 401 à un client JSON anonyme :
it('refuse un accès anonyme aux activités administratives', function (): void {
$client = static::createClient();
requestJson($client, 'GET', '/api/admin/activity');
self::assertResponseStatusCodeSame(401);
});Pour la publication, vérifiez un utilisateur reconnu mais non autorisé, puis un administrateur autorisé. Dans les deux cas, l’article appartient à l’utilisateur de test : la propriété est maîtrisée, le rôle est la variable étudiée.
it('applique la permission de publication', 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() désigne une fonction illustrative qui émet un justificatif d’authentification de test valide pour l’identité donnée et renvoie son en-tête HTTP_AUTHORIZATION. Elle doit employer le mécanisme prévu par l’application. reloadPost() relit l’état enregistré après la requête. Aucune de ces méthodes n’est fournie par Pest. Le contrat choisi permet à l’administrateur de publier et renvoie 204 ; les restrictions liées au tenant, à l’équipe ou à l’état métier demandent leurs propres cas.
Avec un firewall utilisant une session, Symfony loginUser() peut être le bon raccourci de test, à condition de choisir le contexte de sécurité approprié. Il n’authentifie pas les requêtes d’un firewall sans état : il faut alors transmettre le justificatif attendu à chaque requête. Conservez des tests distincts de sa validation lorsqu’un raccourci la contourne. Ne désactivez pas une protection pour faire passer un test de succès ou de refus.
La réinitialisation du mot de passe protège une autre frontière : la réponse publique ne doit pas révéler inutilement l’existence d’un compte. Ici, le stockage des utilisateurs est isolé et contient seulement le compte créé ; le limiteur autorise les deux requêtes :
it('conserve une réponse neutre à la réinitialisation du mot de passe', 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);
});Le contrat de l’exemple prévoit 202 et le même message neutre dans les deux cas. Cette égalité protège contre ces différences observables précises, pas contre tous les canaux possibles. Les durées, les en-têtes ou d’autres observations peuvent encore varier. Les comparaisons de temps ordinaires en CI sont trop instables pour démontrer une résistance à l’énumération des comptes par mesure des délais.
La localisation et la pagination déterminent les données renvoyées
Une route localisée peut répondre 200 tout en sélectionnant le mauvais article. Donnez aux fixtures des slugs explicites et comparez la réponse à des valeurs attendues indépendantes. Déduire le slug attendu du mécanisme de lecture testé masquerait certaines erreurs :
it('renvoie la traduction demandée', 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() enregistre ici un article publié avec exactement ces traductions. Les titres et identifiants des fixtures restent identiques dans les quatre versions linguistiques de l’article. Ajoutez les cas de traduction manquante et de langue non prise en charge selon la politique choisie : 404, langue de repli documentée et redirection sont des contrats différents. Un test de repli doit vérifier la langue réellement renvoyée. Contrôlez aussi que les autres traductions ne sont pas ajoutées par erreur à la réponse publique.
La pagination exige un tri stable, avec un critère de départage lorsque plusieurs dates sont égales. Le cas courant et les paramètres invalides se complètent :
it('renvoie la page demandée', 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('refuse les paramètres de pagination invalides', 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',
]);L’exemple accepte page >= 1 et 1 <= limit <= 100 ; les valeurs hors plage donnent 400. Une autre API peut plafonner la limite ou produire une erreur de validation. Testez la décision documentée. Un dataset convient ici parce que seule l’entrée varie, tandis que le résultat attendu reste identique.
Compter les éléments ne vérifie pas leur ordre. Avec des dates fixes, comparez aussi les ID attendus sur deux pages voisines, vérifiez leur absence de recouvrement et testez une page au-delà de la fin. Les jointures sur les traductions ou les tags méritent un cas capable de révéler des articles dupliqués ou un total artificiellement gonflé.
Vérifier les requêtes avec une base, les décisions avec des objets
Si le risque se trouve dans le DQL, les jointures ou le mapping Doctrine, simuler QueryBuilder passe à côté du problème. Ce contre-exemple ne configure qu’une chaîne d’appels :
$queryBuilder = $this->createMock(\Doctrine\ORM\QueryBuilder::class);
$queryBuilder->method('select')->willReturnSelf();
$queryBuilder->method('join')->willReturnSelf();
$queryBuilder->method('where')->willReturnSelf();Un tel double peut servir à tester une collaboration très précise. Il ne montre pas que la requête sélectionne les bons enregistrements. Priorisez les tests d’intégration des requêtes complexes ou importantes, avec une base isolée dont le moteur et le schéma sont compatibles :
it('ne trouve que les articles publiés dans la langue demandée', 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() fournit l’implémentation Doctrine réelle ; les fonctions de préparation exécutent flush() avant la requête. Ce repository d’exemple renvoie un modèle de lecture d’article ou null, sans langue de repli. Lorsque c’est pertinent, videz l’état des entités gérées pour qu’un objet déjà chargé ne serve pas de preuve d’une nouvelle lecture en base. Toute méthode triviale de repository n’exige pas son propre test d’intégration.
Un service applicatif qui transforme ce modèle de lecture en DTO de sortie peut être testé sans HTTP ni kernel :
it('transforme un article publié en DTO localisé', 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, l’interface du repository et GetPublishedPostService sont des types métier illustratifs. Le builder doit produire le type déclaré par findPublishedBySlug(), et le DTO attendu doit respecter le contrat de get(). L’exemple utilise les mocks déjà fournis par PHPUnit. Mockery n’est pas installé dans ce dépôt ; cette assertion ne justifie pas son ajout.
Séparer l’acceptation d’un travail de son exécution
Pour POST /api/en/contact, un test HTTP utile prouve que la demande a été enregistrée et que le bon message de confirmation a été envoyé au transport. Il n’a pas besoin de démarrer un worker ni d’envoyer un véritable e-mail. Cet extrait suppose que l’environnement de test dirige déjà SendContactConfirmation vers un transport en mémoire nommé async :
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;
it('enregistre la demande et émet sa 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() appartient bien à Symfony et renvoie des enveloppes. Le type du message et sa propriété publique contactRequestId relèvent de l’application fictive ; findContactRequestByEmail() lit la base de test. Récupérez le transport après la création du client, partez d’un stockage vide et limitez ce test à une requête. Sur plusieurs requêtes, les redémarrages du kernel et les réinitialisations de services influencent l’état observable du transport.
messengerTransport() ou queue()->assertCount() ne sont pas des API génériques de Symfony/Pest. Si le projet fournit de tels outils, nommez cette dépendance. Une assertion d’envoi en mémoire ne teste pas non plus un véritable broker, ni nécessairement son chemin de sérialisation. Ces frontières ont leurs propres tests lorsqu’elles présentent un risque.
Le handler se teste séparément :
it('envoie la confirmation et enregistre le résultat', 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);
});Les doubles et createHandler() représentent ici une infrastructure de test illustrative. La fonction doit raccorder un repository contenant la demande fournie, à laquelle le builder attribue un ID fixe, et utiliser exactement le mailer et le repository de rapports passés en paramètres. Les assertions portent alors sur les mêmes objets que ceux utilisés pendant le traitement. Ajoutez des cas de nouvelle livraison lorsqu’ils risquent d’envoyer un second e-mail ou de répéter une opération métier.
Distinguer l’échec applicatif, le rollback et l’idempotence
Un faux repository qui échoue volontairement est utile pour tester le chemin d’erreur d’un service applicatif :
it('signale un échec de persistance de la commande', 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 et la fabrique de service sont illustratifs. Si le double lève une exception avant de conserver la commande, sa collection vide ne dit rien du rollback Doctrine. Un véritable test transactionnel doit exercer la transaction de l’application sur la base de test : provoquer un échec après une écriture réelle, puis vérifier par une nouvelle lecture qu’il ne reste ni commande partielle ni entrée outbox associée. Une transaction externe au test qui annule tout lors du nettoyage peut masquer l’absence de rollback applicatif. Faites l’assertion avant le nettoyage et choisissez une isolation permettant d’étudier de véritables commits.
L’idempotence demande un autre test. Dans cet exemple, l’API rejoue la réponse initiale 201 pour le même appelant, la même clé d’idempotence et un contenu logiquement identique :
it('rejoue la réponse sans créer une seconde commande', 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);
});Les fonctions de préparation du client et du produit enregistrent des fixtures valides. Le client représente ici une identité authentifiable, et le stockage des commandes est initialement vide. Le test vérifie le succès, le même identifiant et une seule commande enregistrée. Comparer uniquement les statuts accepterait aussi deux échecs. Si le contrat prévoit 200 lors du rejeu, exigez-le explicitement. La réutilisation d’une clé avec un autre contenu demande un test distinct de la politique de conflit.
Ce test porte sur deux tentatives successives. Il ne prouve pas la sûreté face à des requêtes concurrentes. Si les doublons simultanés constituent un risque, testez des requêtes en concurrence avec le mécanisme réel, par exemple une contrainte d’unicité et une réservation atomique de l’opération. Pour un effet externe, tenez aussi compte de l’idempotence éventuellement proposée par le fournisseur.
Donner un contrat aux pannes externes et au cache
Une panne GeoIP peut laisser le pays inconnu tout en permettant l’enregistrement d’une activité. C’est une décision de ce produit fictif, pas une règle générale invitant à ignorer les erreurs des fournisseurs :
final class FailingGeoIpResolver implements GeoIpResolverInterface
{
public function resolve(string $ip): GeoIpResult
{
throw new GeoIpUnavailable();
}
}
it('enregistre une activité sans pays en cas de panne GeoIP', 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();
});L’interface du resolver, son résultat, l’exception et recordedActivities() sont des types illustratifs du projet. Le test part d’un stockage d’activités vide et suppose que la requête crée une activité de manière synchrone. Le conteneur de test doit permettre de remplacer le resolver avant l’instanciation de son consommateur. Effectuez ce remplacement après le démarrage du kernel par createClient() et rétablissez l’isolation entre les tests. Les tests automatisés courants utilisent des réponses de fournisseur maîtrisées ; les vérifications volontaires du contrat réel restent séparées.
Pour le cache, précisez ce qui est observable. Si la réduction des lectures du repository est une exigence explicite, compter les appels est justifié :
it('réutilise les réglages en cache pour la même langue', 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);
});La fabrique de service et le cache en mémoire sont illustratifs. Ce test démontre la réutilisation dans cette implémentation et pendant sa durée de vie. Il ne valide ni un adaptateur Redis ni l’invalidation. Un autre test d’intégration devrait précharger les réglages anglais et allemands, modifier le slogan anglais par le chemin d’écriture d’administration, puis vérifier que l’anglais est à jour et que l’allemand reste disponible sans nouvelle lecture — si telle est la règle d’invalidation du projet. Une politique d’invalidation plus large appelle d’autres attentes.
Vérifier les limites dans des conditions maîtrisées
Un test de limitation de débit demande un stockage frais et isolé ainsi qu’une source de temps contrôlée. L’état doit survivre aux requêtes du même test, y compris aux redémarrages du kernel, mais être supprimé ou séparé entre tests et processus parallèles. Le scénario suivant autorise cinq demandes par IP et par fenêtre. Les six tentatives restent dans cette fenêtre et aucune autre protection ne refuse le contenu valide :
it('limite les demandes de contact répétées', 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);
});Cinq est un seuil d’illustration. Le nombre d’enregistrements confirme aussi que la demande refusée n’a rien ajouté. Pour vérifier l’expiration de la fenêtre, utilisez l’horloge de test prise en charge par l’implémentation du limiteur. N’ajoutez pas de véritable sleep() et ne supposez pas qu’un double d’horloge quelconque pilote sa source de temps interne.
Un budget de requêtes est utile lorsqu’un endpoint risque de subir une régression N+1 :
it('borne les requêtes applicatives pour une 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() désigne un collecteur illustratif, pas une méthode Symfony. Il doit observer la connexion Doctrine réellement utilisée par la requête, même après un redémarrage du kernel. La mesure exclut les fixtures, le démarrage du conteneur, la préparation de l’authentification et les requêtes du framework sans rapport avec le cas étudié. Quatre requêtes est un exemple propre à un projet. Faire varier le volume source aide à détecter une croissance, mais un faible nombre de requêtes ne démontre ni une faible latence ni un plan d’exécution efficace.
Un budget de taille de réponse repère d’autres régressions :
it('borne la taille de la liste publique', 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);
});La limite de 300_000 octets concerne le corps non compressé de cet exemple et des fixtures déterministes représentatives. Ce n’est pas une norme pour les API. Elle peut signaler des articles complets, toutes les traductions ou un graphe d’entités devenu trop vaste. Conservez les assertions structurelles : une petite réponse peut tout de même divulguer un secret.
Maîtriser les données, le temps et l’isolation
Utilisez des adresses fixes comme client@example.test, des slugs explicites, des langues, des prix, des ID d’opération et des références externes stables dès qu’ils influencent les assertions. Des données aléatoires peuvent aider à explorer davantage de cas, mais un échec doit rester reproductible. Un petit builder doit rendre visible l’état pertinent :
$post = PostBuilder::new()
->published()
->translated(
locale: 'en',
title: 'Testing Symfony APIs with Pest',
slug: 'testing-symfony-apis-with-pest',
)
->build();Ce PostBuilder est illustratif. build() crée un objet ; son enregistrement est une étape de préparation distincte. Ne cachez pas la publication, la propriété ou le tenant dans des valeurs par défaut si ces données déterminent le résultat.
Employez l’abstraction de temps déjà utilisée par le projet. Symfony MockClock peut remplacer une dépendance compatible, dont Psr\Clock\ClockInterface, sans introduire une classe FrozenClock maison :
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');Cet extrait montre seulement le contrôle du temps. Injectez cette horloge dans le service testé avant de vérifier une date de publication, l’expiration d’un jeton ou le TTL d’un cache. Avancer une horloge locale indépendante n’aura aucun effet sur ces services.
L’isolation de la base peut reposer sur un rollback par test, une remise à zéro, une recréation du schéma ou un rechargement contrôlé des fixtures. Le rollback est souvent rapide, mais n’isole pas les écritures réalisées sur des connexions indépendantes ou dans des systèmes externes. Une remise à zéro ou une recréation coûte davantage, mais permet de tester des changements effectivement validés. Choisissez selon le besoin, isolez les processus parallèles et réinitialisez aussi les files, caches et limiteurs. Utilisez des données synthétiques, jamais celles de production, sans dépendance à l’ordre d’exécution.
Confier aux contrôles statiques et aux schémas leur rôle précis
Les tests d’architecture Pest peuvent imposer certaines règles de dépendance. Ce dépôt comprend le plugin d’architecture de Pest 3 et prend en charge cette syntaxe :
arch('les contrôleurs API ne dépendent pas directement de Doctrine')
->expect('App\Api\Controller')
->not->toUse('Doctrine\ORM\EntityManagerInterface');
arch('le cœur ne dépend pas de la couche HTTP')
->expect('App\Core')
->not->toUse('Symfony\Component\HttpFoundation');Les espaces de noms et restrictions décrivent des règles possibles du projet, pas des obligations Symfony ni la preuve que ce dépôt les respecte déjà. Appliquez uniquement des règles convenues, avec les vrais espaces de noms. Aucun plugin supplémentaire n’est nécessaire dans ce dépôt.
PHPStan devrait analyser les fonctions de test autant que le code applicatif. Les annotations de requestJson() décrivent ses entrées ; responseJson() annonce honnêtement un tableau de valeurs qui restent à valider. Une grande description de tableau en PHPDoc ne valide pas le JSON à l’exécution. Précisez les types par des contrôles, ou utilisez un DTO de réponse existant qui valide ses entrées, au lieu d’affirmer un type que la fonction n’a pas établi. L’analyse statique complète les tests de contrat exécutés.
Si l’API dispose déjà d’un document OpenAPI ou d’un schéma équivalent, validez des réponses représentatives avec les outils existants. Cela peut détecter des champs absents, des types incorrects et des contraintes documentées non respectées. Cela ne prouve ni l’autorisation, ni la sélection correcte en base, ni l’absence d’effets interdits. La présence d’un bundle de documentation ne garantit pas que chaque route possède un schéma exploitable.
La rétrocompatibilité se vérifie elle aussi sur des éléments concrets :
{
"id": 42,
"status": "published",
"publishedAt": "2026-09-17T09:00:00+00:00"
}Remplacer la chaîne ISO 8601 de publishedAt par un timestamp Unix modifie le contrat, même si les deux valeurs représentent le même instant. Gardez une assertion ciblée pour les formats dont dépendent les clients.
Construire un plan autour d’un endpoint
Pour POST /api/{locale}/contact, je répartirais les tests selon l’erreur que chacun doit détecter :
Comportement du contact Responsabilité du test
Normalisation et règles Unitaire : résultat attendu
Persistance, contraintes Intégration : base réelle
Méthode, JSON, validation API : acceptation ou refus
Accès et abus API : protection prévue
Envoi de confirmation API : message et ID du contact
Traitement du message Handler : e-mail et rapport
Réponse publique API : statut, champs, erreurs sûresCette répartition évite de recopier chaque ligne dans trois suites. Commencez par la demande acceptée, puis choisissez les échecs selon les risques réels de l’endpoint. Les exemples précédents fournissent les éléments nécessaires sans transformer le test de contact en cours complet sur Doctrine, Security et Messenger.
La CI devrait fournir rapidement le résultat des contrôles de syntaxe et de l’analyse statique, puis couvrir les tests unitaires, d’intégration et fonctionnels d’API. Ajoutez les contrôles d’architecture, de compatibilité ou E2E pertinents. Des tâches indépendantes peuvent s’exécuter en parallèle ; l’ordre exact et les chemins des suites dépendent du dépôt. Ce projet expose les scripts Composer suivants via Docker :
docker compose exec -T web composer analyse
docker compose exec -T web composer testLa première commande lance l’analyse statique configurée, la seconde la suite de tests du projet. Pour une modification ciblée, choisissez des fichiers ou groupes existants. Un répertoire tests/Unit cité comme exemple n’existe pas nécessairement dans le dépôt.
Avec un assistant IA, fournissez le contrat actuel et les règles de sécurité avant de demander des tests. Vérifiez que les assertions protègent réellement la pagination, les effets de l’opération et les cas autorisés comme refusés. Exécutez les tests pertinents et l’analyse statique : du code plausible ne constitue pas une vérification.
Un test d’API utile rend une promesse précise vérifiable. Choisissez le niveau le moins coûteux qui échouera réellement si elle est violée, et gardez assez de tests HTTP pour vérifier que l’endpoint relie correctement ces comportements.
