Blog
Symfony-APIs mit Pest testen
Zuverlässige Symfony-API-Tests schützen mehr als erfolgreiche JSON-Antworten. Eine gute Pest-Suite prüft Validierung, Autorisierung, Datenbankverhalten, Fehlerverträge, Pagination, Caching, externe Ausfälle und Leistungsgrenzen.
API-Tests prüfen mehr als erfolgreiche Requests
Für einen neuen Endpunkt ist dieser Test ein vernünftiger Anfang:
it('liefert Artikel', function (): void {
$client = static::createClient();
$client->request('GET', '/api/en/posts');
self::assertResponseIsSuccessful();
});Er bestätigt, dass eine bestimmte Anfrage einen erfolgreichen HTTP-Status erhält. Ob Entwürfe aus der Liste verschwinden, die richtige Übersetzung ausgewählt wird oder ein normaler Benutzer keine Artikel veröffentlichen darf, bleibt offen. Für jedes dieser Versprechen braucht es eine Assertion, die bei einem Verstoß tatsächlich fehlschlägt.
Ich würde zunächst den Vertrag des Endpunkts festhalten: Was sendet der Client, welche Berechtigungen braucht er, was erhält er zurück und was verändert sich im System? Erst danach entscheide ich, welche Eigenschaften einen HTTP-Test benötigen und welche sich direkter prüfen lassen.
Die Beispiele orientieren sich an Symfony 7.4, Pest 3 und PHPUnit 11. Sie beschreiben eine beispielhafte API, nicht die produktive GiSoft-API. Routen, Antwortformate und Statuscodes gehören zu diesem Beispiel. Fachliche Klassen, Builder und Fixture-Helfer stehen für projektspezifische Testinfrastruktur; Implementierungen und Imports sind teilweise ausgelassen. Die Ausschnitte lassen sich daher nicht unverändert als vollständige Testsuite ausführen.
Die Testebene richtet sich nach dem Risiko
Projekte benennen ihre Testsuiten unterschiedlich. Hier prüft ein Unit-Test ein Objekt ohne Symfony-Kernel. Ein Integrationstest verwendet die jeweils relevante echte Infrastruktur. Ein funktionaler API-Test schickt eine Anfrage durch den Symfony-Testclient. Dieser arbeitet innerhalb des Anwendungsprozesses und prüft weder einen echten Browser noch Reverse Proxy oder Netzwerk der Zielumgebung.
Zu prüfendes Verhalten Günstigster aussagekräftiger Einstieg
Fachliche Berechnung Unit-Test
Abfrage und Mapping Integrationstest mit Datenbank
HTTP-Antwortvertrag Funktionaler API-Test
Berechtigung am Objekt Policy/Voter + HTTP-Grenze
Kritischer Benutzerablauf Wenige E2E-TestsEin Policy-Test kann viele Eigentumskonstellationen mit geringem Aufwand prüfen. Der HTTP-Test stellt fest, ob die Route diese Policy überhaupt anwendet. Beide decken unterschiedliche Fehler ab. Dafür muss nicht jede Kombination auf jeder Ebene wiederholt werden.
Pest und vorhandene PHPUnit-Klassen können nebeneinander bestehen. Eine Umstellung lohnt sich, wenn sie die Wartung erleichtert; ein einheitlicher Schreibstil allein ist kein ausreichender Grund. Für ein Projekt mit bewusst angelegten Verzeichnissen tests/Integration und tests/Functional ist folgende Konfiguration in tests/Pest.php unter Pest 3 gültig:
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
uses(KernelTestCase::class)->in('Integration');
uses(WebTestCase::class)->in('Functional');Die Verzeichnisnamen dienen als Beispiel; dieses Repository ordnet Tests nach den Namensräumen der Anwendung. Ohne abweichende Konfiguration verwendet Pest PHPUnit TestCase. Reine Objekttests brauchen weder einen leeren uses()-Aufruf noch einen Kernel. Die folgenden HTTP-Tests setzen WebTestCase voraus, die Repository-Tests KernelTestCase. Projektspezifische Fixture-Traits müssen dort eingebunden sein, wo die Tests sie verwenden.
Den HTTP-Vertrag im Test sichtbar lassen
Ein Helfer darf wiederholtes JSON-Encoding übernehmen. Methode, URL, Header und Nutzdaten sollten trotzdem erkennbar bleiben. Diese beiden Funktionen sind Hilfsfunktionen des Artikels, keine eingebauten Pest- oder 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;
}null bedeutet hier: kein Request-Body. [] wird dagegen als JSON-Array codiert. Ein leeres JSON-Objekt lässt sich mit dem Rohtext {} über $client->request() testen. Auch absichtlich beschädigtes JSON muss am Encoder vorbei gesendet werden. responseJson() prüft die Decodierung und den obersten PHP-Typ, aber kein Antwortschema.
Der Beispielvertrag für GET /api/{locale}/posts?page=1&limit=20 liefert items, pagination und locale. Ausgangspunkt ist eine isolierte Datenbank. Erstellen Sie den Client vor Helfern, die den Container verwenden, und speichern Sie einen veröffentlichten Artikel sowie einen Entwurf:
it('liefert eine lokalisierte Seite veröffentlichter Artikel', 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() und createDraftPost() bezeichnen deterministische Fixture-Helfer, die ihre Datensätze speichern und flush() ausführen. Sind nur diese beiden Artikel vorhanden, prüfen Anzahl und Titel gemeinsam, dass der Entwurf ausgeschlossen bleibt. Die ausdrückliche Feldliste des Autors schützt die Serialisierung: Eine zusätzlich ausgegebene private E-Mail-Adresse oder ein Passwort-Hash lässt den Test fehlschlagen.
Eine Suche nach password im JSON-Rohtext ist deutlich schwächer. Das Feld könnte anders heißen, und ein öffentlicher Artikel darf dieses Wort enthalten. Die Suche nach einem konkreten geheimen Fixture-Wert kann ergänzen; für ein bekanntes öffentliches Objekt sind strukturelle Assertions die bessere Grundlage.
toHaveKeys() erlaubt zusätzliche Felder. Ein exakter Vergleich der Schlüssel schließt sie bewusst aus. Maßgeblich ist der Vertrag. Der Vergleich einer vollständigen JSON-Zeichenkette reagiert häufig unnötig auf Leerraum und Schlüsselreihenfolge. Für eine absichtlich festgeschriebene Antwort oder Referenzdatei kann er trotzdem sinnvoll sein. Schlüssel allein reichen ebenfalls nicht: Werte und Typen müssen stimmen.
Ungültige Eingaben und verlässliche Fehlerantworten
Eine fachlich ungültige Eingabe ist etwas anderes als ein Body, der sich nicht decodieren lässt. In dieser Beispiel-API ergeben Feldfehler 422, fehlerhaftes JSON 400 und ein nicht unterstützter Medientyp 415. Das sind ausdrücklich gewählte Verträge, keine allgemeingültigen Symfony-Vorgaben. Prüfen Sie die dokumentierten Statuscodes Ihrer Anwendung.
it('lehnt ungültige Kontaktdaten ohne Speicherung ab', 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']],
]);Das Dataset umfasst leere und fehlende Pflichtfelder. contactRequestCount() steht für einen Datenbankhelfer; die Kontakttabelle ist zu Testbeginn leer. Neben der Ablehnung prüft der Test, dass kein Datensatz entstanden ist. Wenn eine E-Mail, ein Anbieteraufruf oder ein versendeter Auftrag das relevante Risiko darstellt, prüfen Sie gezielt dessen Ausbleiben. Nicht jeder Validierungstest muss sämtliche Abhängigkeiten beobachten.
it('lehnt fehlerhaftes JSON ab', 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('lehnt einen nicht unterstützten Medientyp ab', 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);
});Ein fehlender Content-Type und ein nicht erfüllbarer Accept-Header sind eigene Fälle. Content-Type beschreibt den gesendeten Body, Accept die akzeptierten Antwortdarstellungen. Testen Sie die dokumentierte Behandlung, einschließlich 406, wenn die Inhaltsaushandlung dies vorsieht. JSON-Endpunkte müssen dafür nicht alle dieselbe Regel verwenden.
Öffentliche Fehler benötigen eine stabile Bedeutung, ohne Exception-Klassen, Stacktraces, SQL, Dateipfade oder Zugangsdaten offenzulegen. Für eine englische Artikelroute könnte die Antwort so aussehen:
{
"error": {
"code": "post_not_found",
"message": "The requested article was not found.",
"details": []
}
}Interner Diagnosekontext gehört in geschützte Logs. Der öffentliche Fehlercode und die unbedenkliche Meldung werden davon unabhängig geprüft:
it('liefert den öffentlichen Fehler für fehlende Artikel', 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('lehnt DELETE auf der öffentlichen Leseroute ab', function (): void {
$client = static::createClient();
requestJson($client, 'DELETE', '/api/en/posts/example');
self::assertResponseStatusCodeSame(405);
});Der zweite Test setzt voraus, dass der öffentliche Pfad als Leseroute existiert und keine DELETE-Route definiert ist. Bei einem 405-Vertrag kann zusätzlich eine Assertion auf Allow falsch ausgewiesene Methoden erkennen. Prüfen Sie Header, auf die Clients angewiesen sind, etwa den JSON-Medientyp aus dem Listentest, statt sämtliche Framework-Header einzufrieren.
Erlaubte und abgelehnte Zugriffe prüfen
Authentifizierung stellt die Identität des Aufrufers fest. Autorisierung entscheidet, ob eine Operation erlaubt ist. Nach der üblichen HTTP-Semantik weist 401 auf fehlende oder ungültige Anmeldedaten hin; 403 bedeutet, dass die Erfüllung der Anfrage verweigert wird. Ein 403 allein beweist keine erfolgreiche Authentifizierung. Entry Point, Weiterleitungen und Fehlerbehandlung beeinflussen die konkrete Antwort. Der API-Vertrag muss deshalb vorher feststehen.
Unsere Beispiel-API antwortet einem anonymen JSON-Client mit 401:
it('lehnt anonymen Zugriff auf Verwaltungsaktivitäten ab', function (): void {
$client = static::createClient();
requestJson($client, 'GET', '/api/admin/activity');
self::assertResponseStatusCodeSame(401);
});Beim Veröffentlichen prüfen wir einen bekannten Benutzer ohne Berechtigung und einen berechtigten Administrator. Der Artikel gehört jeweils dem Testbenutzer; damit ist die Eigentumsfrage kontrolliert, während die Rolle variiert:
it('setzt die Veröffentlichungsberechtigung durch', 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() ist ein beispielhafter Helfer, der gültige Testzugangsdaten für die angegebene Identität ausstellt und den zugehörigen HTTP_AUTHORIZATION-Header zurückgibt. Er muss den vorgesehenen Authentifizierungsmechanismus verwenden. reloadPost() liest den gespeicherten Zustand nach der Anfrage erneut ein. Keine dieser Methoden gehört zu Pest. Der Beispielvertrag erlaubt dem Administrator die Veröffentlichung und antwortet mit 204; Einschränkungen nach Mandant, Team oder fachlichem Zustand brauchen weitere Fälle.
Bei einer zustandsbehafteten Firewall kann Symfony loginUser() mit dem passenden Sicherheitskontext den Test vereinfachen. Für eine zustandslose Firewall funktioniert dieser Weg nicht: Dort muss jede Anfrage die benötigten Zugangsdaten mitbringen. Prüfen Sie deren Validierung separat, wenn ein Testhelfer sie umgeht. Sicherheitsprüfungen werden nicht abgeschaltet, nur damit ein Erfolgs- oder Ablehnungstest besteht.
Beim Zurücksetzen eines Passworts geht es um eine weitere Grenze: Die öffentliche Antwort soll nicht unnötig verraten, ob ein Konto existiert. Der folgende Test verwendet einen isolierten Benutzerbestand mit genau dem angelegten Konto und einen Limiter, der beide Anfragen zulässt:
it('hält die Antwort zum Zurücksetzen des Passworts 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);
});Der Beispielvertrag liefert in beiden Fällen 202 und dieselbe neutrale Meldung. Damit werden diese konkreten Unterschiede ausgeschlossen, nicht sämtliche möglichen Informationskanäle. Antwortzeiten, Header oder andere Beobachtungen können weiterhin abweichen. Übliche Zeitmessungen in CI sind zu unzuverlässig, um Schutz vor zeitbasierter Kontenermittlung nachzuweisen.
Lokalisierung und Seitennavigation betreffen die Datenauswahl
Eine übersetzte Route kann 200 liefern und trotzdem den falschen Datensatz auswählen. Geben Sie den Fixtures feste Slugs und vergleichen Sie die Antwort mit unabhängigen Erwartungswerten. Den erwarteten Slug aus demselben Zugriff abzuleiten, der geprüft werden soll, würde den Test schwächen:
it('liefert die angeforderte Übersetzung', 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() speichert hier einen veröffentlichten Artikel mit genau diesen Übersetzungen. Fixture-Titel und Bezeichner bleiben in allen Sprachfassungen des Artikels identisch. Ergänzen Sie fehlende Übersetzungen und nicht unterstützte Sprachkennungen gemäß der gewählten Regel: 404, eine dokumentierte Ersatzsprache und eine Weiterleitung sind unterschiedliche Verträge. Ein Fallback-Test muss die tatsächlich gelieferte Sprache prüfen. Andere Übersetzungen dürfen nicht versehentlich mit ausgegeben werden.
Pagination setzt eine stabile Sortierung voraus, einschließlich eines eindeutigen zweiten Kriteriums bei gleichen Datumswerten. Der normale Seitenabruf und Varianten ungültiger Parameter passen zusammen:
it('liefert die angeforderte Seite', 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('lehnt ungültige Pagination ab', 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',
]);Das Beispiel akzeptiert page >= 1 und 1 <= limit <= 100; Werte außerhalb des Bereichs führen zu 400. Eine andere API kann den Grenzwert begrenzen oder einen Validierungsfehler liefern. Testen Sie die dokumentierte Entscheidung. Das Dataset ist hier passend, weil sich nur die Eingabe ändert und die Erwartung gleich bleibt.
Die Anzahl der Elemente sagt noch nichts über ihre Reihenfolge aus. Vergleichen Sie mit festen Fixture-Daten zusätzlich die erwarteten IDs benachbarter Seiten, schließen Sie Überschneidungen aus und prüfen Sie eine Seite hinter dem Ende. Bei Joins über Übersetzungen oder Tags sollte ein Fall doppelte Artikel beziehungsweise aufgeblähte Gesamtzahlen aufdecken.
Abfragen mit Datenbank, Entscheidungen mit Objekten prüfen
Wenn das Risiko in DQL, Joins oder Doctrine-Mapping liegt, hilft ein gemockter QueryBuilder kaum. Dieses beispielhafte Gegenbeispiel konfiguriert lediglich eine Aufrufkette:
$queryBuilder = $this->createMock(\Doctrine\ORM\QueryBuilder::class);
$queryBuilder->method('select')->willReturnSelf();
$queryBuilder->method('join')->willReturnSelf();
$queryBuilder->method('where')->willReturnSelf();Ein solcher Mock kann eine eng umrissene Zusammenarbeit prüfen. Er zeigt aber nicht, ob eine Abfrage die richtigen Datensätze auswählt. Konzentrieren Sie Integrationstests auf komplexe oder wichtige Repository-Abfragen und verwenden Sie eine isolierte Datenbank mit kompatibler Engine und passendem Schema:
it('findet nur veröffentlichte Artikel in der gewünschten Sprache', 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() liefert die echte Doctrine-Implementierung; die Fixture-Helfer führen vor der Abfrage flush() aus. Das Beispiel-Repository gibt ein Artikel-Lesemodell oder null zurück und verwendet keine Ersatzsprache. Leeren Sie bei Bedarf den Zustand verwalteter Entitäten, damit ein bereits geladenes Objekt nicht als Beleg für einen frischen Datenbankzugriff dient. Eine eigene Integrationsprüfung für jede triviale Repository-Methode ist nicht nötig.
Ein Anwendungsservice, der dieses Lesemodell in ein Ausgabe-DTO überführt, lässt sich ohne HTTP und Kernel testen:
it('bildet einen veröffentlichten Artikel auf ein lokalisiertes DTO ab', 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, Repository-Interface und GetPublishedPostService sind beispielhafte fachliche Typen. Der Builder muss den von findPublishedBySlug() deklarierten Typ liefern; das erwartete DTO muss zum Vertrag von get() passen. Das Beispiel verwendet vorhandene PHPUnit-Mocks. Mockery ist in diesem Repository nicht installiert und wird für diese Erwartung nicht benötigt.
Aufträge annehmen und Aufträge ausführen getrennt testen
Für POST /api/en/contact kann ein HTTP-Test nachweisen, dass die Kontaktanfrage gespeichert und die richtige Bestätigungsnachricht versendet wurde. Dafür muss weder ein Worker laufen noch eine echte E-Mail versendet werden. Der Ausschnitt setzt voraus, dass die Testumgebung SendContactConfirmation bereits an einen In-Memory-Transport namens async weiterleitet:
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;
it('speichert die Kontaktanfrage und versendet ihre Bestätigungsnachricht', 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() ist eine Symfony-Methode und liefert Envelopes. Nachrichtentyp und öffentliche Eigenschaft contactRequestId gehören zur Beispielanwendung; findContactRequestByEmail() liest die Testdatenbank. Holen Sie den Transport nach dem Erstellen des Clients, beginnen Sie mit leerem Speicher und verwenden Sie hier nur eine Anfrage. Bei mehreren Anfragen wirken sich Kernel-Neustarts und Service-Resets auf den beobachtbaren Transportzustand aus.
messengerTransport() und queue()->assertCount() sind keine allgemeinen Symfony/Pest-APIs. Stellt ein Projekt solche Helfer bereit, muss diese Abhängigkeit klar sein. Ein Versand in den Arbeitsspeicher prüft außerdem weder einen echten Broker noch zwangsläufig dessen Serialisierung. Relevante Infrastrukturgrenzen benötigen eigene Tests.
Der Handler wird separat geprüft:
it('sendet die Bestätigung und speichert das Ergebnis', 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);
});Die Fakes und createHandler() stehen für beispielhafte Testinfrastruktur. Der Helfer muss ein Repository mit dem übergebenen Kontakt anschließen, dessen Builder eine feste ID vergibt. Er muss außerdem genau den gezeigten Mailer und das gezeigte Report-Repository verwenden. Erst die Prüfung derselben Instanzen macht die Assertions zu den Auswirkungen aussagekräftig. Ergänzen Sie erneute Zustellungen, wenn sie eine zweite E-Mail oder eine wiederholte fachliche Operation auslösen könnten.
Fehlerbehandlung, Rollback und Idempotenz unterscheiden
Ein Repository-Double, das absichtlich fehlschlägt, ist für den Fehlerpfad eines Anwendungsservices sinnvoll:
it('meldet einen Fehler beim Speichern des Auftrags', 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 und die Service-Fabrik sind beispielhaft. Wirft der Fake schon vor dem Speichern, sagt seine leere Sammlung nichts über einen Doctrine-Rollback aus. Ein echter Transaktionstest muss die Transaktionsgrenze der Anwendung gegen die Testdatenbank ausführen: nach einem tatsächlichen Schreibzugriff einen Fehler erzwingen und anschließend frisch prüfen, dass weder ein unvollständiger Auftrag noch ein zugehöriger Outbox-Eintrag übrig bleibt. Eine äußere Testtransaktion, die erst beim Aufräumen alles zurückrollt, kann einen fehlenden Anwendungsrollback verdecken. Prüfen Sie den Zustand davor und wählen Sie eine Isolation, mit der sich echte Commits untersuchen lassen.
Idempotenz ist eine eigene Eigenschaft. In diesem Beispiel wiederholt die API für denselben Aufrufer, denselben Idempotenzschlüssel und logisch identische Nutzdaten die ursprüngliche 201-Antwort:
it('wiederholt die Antwort ohne einen zweiten Auftrag anzulegen', 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);
});Kunden- und Produkthelfer speichern gültige Fixtures. Der Kunde ist hier eine authentifizierbare Identität; die Auftragstabelle ist zu Beginn leer. Geprüft werden der Erfolg, dieselbe Auftrags-ID und genau ein gespeicherter Auftrag. Ein bloßer Statusvergleich würde auch zwei fehlgeschlagene Anfragen akzeptieren. Sieht der Vertrag bei Wiederholung 200 vor, muss der Test genau das verlangen. Derselbe Schlüssel mit anderem Inhalt benötigt einen eigenen Test für die Konfliktregel.
Dieser Test prüft nacheinander ausgeführte Wiederholungen. Nebenläufigkeit ist damit nicht abgesichert. Wo konkurrierende Anfragen relevant sind, testen Sie diese gegen den tatsächlichen Schutz, etwa eine Eindeutigkeitsbedingung mit atomarer Reservierung der Operation. Bei externen Auswirkungen ist gegebenenfalls auch die Idempotenzunterstützung des Anbieters einzubeziehen.
Externe Ausfälle und Cache-Verhalten brauchen klare Erwartungen
Bei einem GeoIP-Ausfall könnte das optionale Land unbekannt bleiben, während die Anwendung trotzdem einen Aktivitätseintrag speichert. Das ist eine Entscheidung für dieses Beispielprodukt, keine allgemeine Empfehlung, Anbieterfehler zu ignorieren:
final class FailingGeoIpResolver implements GeoIpResolverInterface
{
public function resolve(string $ip): GeoIpResult
{
throw new GeoIpUnavailable();
}
}
it('speichert bei GeoIP-Ausfall eine Aktivität ohne Land', 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();
});Resolver-Interface, Ergebnis, Exception und recordedActivities() sind beispielhafte Projekttypen. Der Test beginnt mit leerem Aktivitätsspeicher und setzt voraus, dass die Anfrage synchron genau eine Aktivität erzeugt. Der Testcontainer muss den Austausch des Resolvers erlauben, bevor dessen Verbraucher instanziiert wird. Ersetzen Sie ihn nach dem Kernel-Start durch createClient() und stellen Sie zwischen Tests die Isolation wieder her. Reguläre automatisierte Tests verwenden kontrollierte Anbieterantworten; bewusst durchgeführte Vertragstests mit dem Anbieter laufen separat.
Beim Cache sollte klar sein, welches Verhalten zugesichert wird. Ist die Reduktion wiederholter Repository-Zugriffe ein ausdrückliches Ziel, darf der Test die Aufrufzahl prüfen:
it('verwendet Einstellungen derselben Sprache aus dem Cache erneut', 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);
});Service-Fabrik und In-Memory-Cache sind beispielhaft. Der Test belegt die Wiederverwendung innerhalb dieser Implementierung und Lebensdauer. Er prüft weder einen Redis-Adapter noch die Invalidierung. Ein eigener Integrationstest sollte englische und deutsche Einstellungen vorladen, den englischen Slogan über den administrativen Schreibpfad ändern und anschließend frische englische Werte sowie weiterhin ohne Neuladen verfügbare deutsche Werte prüfen — sofern dies die Invalidierungsregel des Projekts ist. Eine bewusst weiter gefasste Invalidierung verlangt andere Erwartungen.
Betriebsgrenzen unter kontrollierten Bedingungen prüfen
Ein Rate-Limit-Test braucht frischen, isolierten Zustand und eine kontrollierte Zeitquelle. Der Zustand muss innerhalb des Tests über mehrere Anfragen und Kernel-Neustarts erhalten bleiben. Zwischen Tests und parallelen Prozessen muss er dagegen gelöscht oder eindeutig getrennt werden. Im folgenden Szenario sind fünf Kontaktanfragen pro IP und Zeitfenster erlaubt. Alle sechs Versuche liegen in diesem Fenster, und keine andere Schutzmaßnahme weist die gültigen Nutzdaten ab:
it('begrenzt wiederholte Kontaktanfragen', 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);
});Fünf ist ein Beispielwert. Die Anzahl gespeicherter Datensätze prüft zusätzlich, dass die abgelehnte Anfrage keinen weiteren Eintrag erzeugt. Prüfen Sie das Ablaufen des Fensters mit der von der Limiter-Implementierung unterstützten Testuhr. Verwenden Sie kein echtes sleep() und nehmen Sie nicht an, jede beliebige Clock-Attrappe steuere die interne Zeitquelle des Limiters.
Bei einem Endpunkt mit N+1-Risiko kann ein Abfragebudget Regressionen sichtbar machen:
it('begrenzt Anwendungsabfragen für eine Listenseite', 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() ist ein beispielhafter Collector-Helfer, keine Symfony-Methode. Er muss die tatsächlich von der Anfrage verwendete Doctrine-Verbindung beobachten, auch nach einem Kernel-Neustart. Fixture-Erstellung, Containerstart, Authentifizierungssetup und fachfremde Framework-Abfragen gehören nicht in diese Messung. Vier Abfragen sind ein projektspezifischer Beispielwert. Unterschiedliche Datenmengen helfen, Wachstum zu erkennen; wenige Abfragen beweisen aber weder niedrige Latenz noch effiziente Ausführungspläne.
Ein Größenbudget für Antworten erfasst andere Regressionen:
it('begrenzt die Größe der öffentlichen Listenantwort', 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);
});Die Grenze von 300_000 Byte bezieht sich auf den unkomprimierten Body dieses Beispiels und repräsentative, deterministische Fixtures. Sie ist keine allgemeine API-Vorgabe. Sie kann vollständige Artikeltexte, sämtliche Übersetzungen oder einen unerwartet großen Entitätsgraphen auffallen lassen. Strukturelle Feldprüfungen bleiben nötig: Auch eine kleine Antwort kann ein Geheimnis preisgeben.
Daten, Zeit und Isolation vorhersehbar halten
Verwenden Sie feste E-Mail-Adressen wie client@example.test sowie explizite Slugs, Sprachkennungen, Preise, Operations-IDs und Anbieterreferenzen, wenn diese Werte die Assertions beeinflussen. Zufallsdaten können bei weitergehenden Untersuchungen helfen, doch Fehler müssen reproduzierbar sein. Ein kleiner Builder sollte den entscheidenden Zustand zeigen:
$post = PostBuilder::new()
->published()
->translated(
locale: 'en',
title: 'Testing Symfony APIs with Pest',
slug: 'testing-symfony-apis-with-pest',
)
->build();Dieser PostBuilder ist beispielhaft. build() erzeugt ein Objekt; die Persistierung ist ein separater Fixture-Schritt. Verstecken Sie Veröffentlichung, Eigentum oder Mandant nicht in Standardwerten, wenn diese über das Ergebnis entscheiden.
Verwenden Sie die Zeitabstraktion, die das Projekt bereits vorsieht. Symfony MockClock kann eine kompatible Abhängigkeit einschließlich Psr\Clock\ClockInterface ersetzen, ohne eine eigene FrozenClock einzuführen:
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');Dieser Ausschnitt demonstriert nur die kontrollierte Zeit. Injizieren Sie diese Uhr in den getesteten Service, bevor Sie Veröffentlichungszeitpunkte, Token-Ablauf oder Cache-TTL prüfen. Eine unabhängig erzeugte lokale Uhr beeinflusst diese Services nicht.
Zur Datenbankisolation eignen sich je nach Bedarf Rollback pro Test, Datenbankreset, Neuerstellung des Schemas oder kontrolliertes Neuladen von Fixtures. Rollback ist häufig schnell, erfasst aber keine Schreibzugriffe über unabhängige Verbindungen oder externe Systeme. Reset und Neuerstellung kosten mehr, erlauben dafür Tests von tatsächlich bestätigten Änderungen. Trennen Sie parallele Prozesse und setzen Sie auch Queues, Caches und Limiter zurück. Verwenden Sie synthetische statt produktiver Daten und vermeiden Sie Abhängigkeiten von der Testreihenfolge.
Statische Prüfungen und Schemas gezielt ergänzen
Pest-Architekturtests können ausgewählte Abhängigkeitsregeln durchsetzen. Dieses Repository enthält das Architektur-Plugin für Pest 3; folgende Syntax wird unterstützt:
arch('API-Controller greifen nicht direkt auf Doctrine zu')
->expect('App\Api\Controller')
->not->toUse('Doctrine\ORM\EntityManagerInterface');
arch('Core hängt nicht von der HTTP-Verarbeitung ab')
->expect('App\Core')
->not->toUse('Symfony\Component\HttpFoundation');Namensräume und Einschränkungen beschreiben mögliche Projektregeln. Sie sind weder Symfony-Vorschriften noch ein Nachweis, dass dieses Repository sie bereits erfüllt. Verwenden Sie vereinbarte Regeln mit den tatsächlichen Namensräumen des Projekts. In diesem Repository ist kein zusätzliches Plugin erforderlich.
PHPStan sollte neben Anwendungscode auch Testhelfer analysieren. Die Annotationen von requestJson() beschreiben die Eingaben; responseJson() deklariert bewusst ein Array mit noch ungeprüften Werten. Eine ausführliche PHPDoc-Array-Shape validiert kein JSON zur Laufzeit. Grenzen Sie Typen durch Prüfungen ein oder nutzen Sie ein vorhandenes, validierendes Antwort-DTO, statt einen ungeprüften Typ zu behaupten. Statische Analyse ergänzt die ausgeführten Vertragstests.
Existiert bereits ein OpenAPI-Dokument oder ein gleichwertiges Schema, lassen sich repräsentative Antworten mit vorhandenen Werkzeugen dagegen validieren. Das erkennt fehlende Felder, falsche Typen und dokumentierte Einschränkungen. Es prüft weder Berechtigungen noch die richtige Datenauswahl oder das Ausbleiben verbotener Auswirkungen. Ein installiertes Dokumentations-Bundle allein bedeutet nicht, dass jede Route ein brauchbares Schema besitzt.
Auch Rückwärtskompatibilität hat überprüfbare Eigenschaften:
{
"id": 42,
"status": "published",
"publishedAt": "2026-09-17T09:00:00+00:00"
}Wird publishedAt von dieser ISO-8601-Zeichenkette auf einen Unix-Zeitstempel umgestellt, ändert sich der Vertrag, selbst wenn beide denselben Zeitpunkt darstellen. Für Formate, von denen Clients abhängen, lohnt sich eine gezielte Kompatibilitätsprüfung.
Einen konkreten Endpunkt als Testplan verwenden
Für POST /api/{locale}/contact würde ich Tests danach zuordnen, welchen Fehler sie aufdecken sollen:
Kontaktverhalten Aufgabe des Tests
Normalisierung, Regeln Unit: beabsichtigte Ergebnisdaten
Speicherung, Constraints Integration: echte Datenbank
Methode, JSON, Validierung API: Annahme oder Ablehnung
Zugriff, Missbrauchsschutz API: vorgesehene Sicherheitsgrenze
Bestätigung versenden API: Nachricht und Kontakt-ID
Bestätigung verarbeiten Handler: E-Mail und Bericht
Öffentliche Antwort API: Status, Felder, sichere FehlerDie Übersicht teilt Verantwortung auf. Sie verlangt nicht, jede Zeile in drei Suiten nachzubauen. Beginnen Sie mit der akzeptierten Anfrage und wählen Sie Fehlerfälle anhand der tatsächlichen Risiken. Dafür liefern die Beispiele Bausteine, ohne den Kontakttest zu einem vollständigen Lehrgang über Doctrine, Security und Messenger zu machen.
CI sollte rasch Ergebnisse aus Syntaxprüfung und statischer Analyse liefern und anschließend Unit-, Integrations- und funktionales API-Verhalten abdecken. Je nach Bedarf kommen Architektur-, Kompatibilitäts- oder E2E-Prüfungen hinzu. Unabhängige Jobs können parallel laufen; Reihenfolge und Suite-Pfade richten sich nach dem Repository. Dieses Projekt stellt folgende Composer-Skripte über Docker bereit:
docker compose exec -T web composer analyse
docker compose exec -T web composer testDer erste Befehl startet die konfigurierte statische Analyse, der zweite die Testsuite des Projekts. Wählen Sie für begrenzte Änderungen vorhandene Testdateien oder Gruppen aus. Ein beispielhaft genanntes tests/Unit muss im Repository nicht existieren.
Geben Sie einem KI-Assistenten zuerst den bestehenden Vertrag und die Sicherheitsregeln, bevor Sie Tests anfordern. Prüfen Sie, ob seine Assertions Pagination, Auswirkungen und sowohl erlaubte als auch abgelehnte Operationen absichern. Führen Sie die relevanten Tests und die statische Analyse aus; plausibel aussehender Code ist noch kein Nachweis.
Ein brauchbarer API-Test macht ein konkretes Versprechen überprüfbar. Wählen Sie die günstigste Ebene, auf der ein Verstoß tatsächlich auffällt, und behalten Sie genug HTTP-Tests, um das korrekte Zusammenspiel im Endpunkt nachzuweisen.
