Blog
Jak testować API Symfony za pomocą Pest
Dobre testy API Symfony zabezpieczają więcej niż poprawną odpowiedź JSON. Zestaw Pest weryfikuje walidację, autoryzację, bazę danych, kontrakty błędów, paginację, cache, awarie usług i granice wydajności.
Testowanie API to nie tylko wysyłanie żądań
Przy nowym endpoincie taki test jest rozsądnym początkiem:
it('zwraca artykuły', function (): void {
$client = static::createClient();
$client->request('GET', '/api/en/posts');
self::assertResponseIsSuccessful();
});Potwierdza, że konkretne żądanie otrzymało odpowiedź z kodem sukcesu. Nie sprawdza jednak, czy lista pomija szkice, zwraca właściwe tłumaczenie ani czy zwykły użytkownik nie może opublikować artykułu. Każde z tych zachowań wymaga asercji, która zawiedzie po jego naruszeniu.
Zacząłbym od kontraktu endpointu: co klient wysyła, do czego ma prawo, jakiej odpowiedzi oczekuje i co żądanie zmienia w systemie. Dopiero potem wybrałbym zachowania wymagające testu HTTP oraz te, które można sprawdzić bezpośrednio.
Przykłady wykorzystują konwencje Symfony 7.4, Pest 3 i PHPUnit 11. Opisują umowne API, a nie działające API GiSoft. Trasy, formaty odpowiedzi i wybrane statusy należą do tego przykładu. Klasy domenowe, buildery i helpery danych testowych oznaczają skróconą infrastrukturę testową projektu; pominięto ich implementacje i importy. To wzorce do dostosowania, nie zestaw uruchamiany bez zmian po skopiowaniu.
Dobierz poziom testu do ryzyka
Nazwy zestawów testowych zależą od projektu. Tutaj test jednostkowy sprawdza obiekt bez uruchamiania Symfony, integracyjny korzysta z potrzebnej rzeczywistej infrastruktury, a funkcjonalny API wysyła żądanie przez klienta testowego Symfony. Ten klient działa wewnątrz procesu aplikacji. Nie sprawdza prawdziwej przeglądarki, reverse proxy ani sieci środowiska wdrożeniowego.
Sprawdzane zachowanie Najprostszy miarodajny poziom
Obliczenie biznesowe Test jednostkowy
Zapytanie i mapowanie Integracyjny z bazą danych
Kontrakt odpowiedzi HTTP Funkcjonalny API
Dostęp do obiektu Polityka/voter + granica HTTP
Krytyczna ścieżka klienta Niewielka liczba testów E2ETest polityki uprawnień może tanio sprawdzić wiele wariantów własności obiektu. Test HTTP potwierdza, że endpoint rzeczywiście stosuje tę politykę. Zabezpieczają różne rodzaje błędów; nie trzeba powtarzać wszystkich kombinacji na każdym poziomie.
Pest może działać obok istniejących klas PHPUnit. Przepisuj działający test wtedy, gdy ułatwi to jego utrzymanie, a nie tylko ujednolici zapis. W projekcie z celowo wydzielonymi katalogami tests/Integration i tests/Functional poniższa konfiguracja w tests/Pest.php jest poprawna dla Pest 3:
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
uses(KernelTestCase::class)->in('Integration');
uses(WebTestCase::class)->in('Functional');Nazwy katalogów są przykładowe; to repozytorium organizuje testy według przestrzeni nazw aplikacji. Bez dodatkowej konfiguracji Pest używa bazowej klasy TestCase PHPUnit. Zwykłe testy obiektów nie potrzebują pustego uses() ani kernela. Poniższe testy HTTP wymagają powiązania z WebTestCase, a testy repozytorium z KernelTestCase oraz, tam gdzie są używane, traitów projektu obsługujących dane testowe.
Pokaż kontrakt HTTP w teście
Helper przydaje się, gdy usuwa powtarzane kodowanie JSON, ale nadal widać metodę, adres, nagłówki i treść żądania. Te dwie funkcje są helperami z artykułu, nie wbudowanym API Pest ani 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;
}null oznacza tu brak treści żądania, a [] zostaje zakodowane jako tablica JSON. Pusty obiekt JSON sprawdź, wysyłając surowe {} przez $client->request(). Tak samo omiń encoder przy celowo uszkodzonym JSON. responseJson() sprawdza dekodowanie i typ wartości na najwyższym poziomie; nie waliduje schematu odpowiedzi.
Przykładowy kontrakt listy to GET /api/{locale}/posts?page=1&limit=20 i odpowiedź z polami items, pagination oraz locale. Zacznij od odizolowanej bazy. Utwórz klienta przed helperami korzystającymi z kontenera, a następnie zapisz jeden opublikowany artykuł i jeden szkic:
it('zwraca stronę opublikowanych artykułów we właściwym języku', 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() i createDraftPost() oznaczają deterministyczne helpery zapisujące rekordy wraz z flush(). Gdy w bazie są tylko te dwa artykuły, asercje liczby wyników i tytułu potwierdzają odfiltrowanie szkicu. Jawna lista pól autora chroni granicę serializacji: dodanie prywatnego adresu e-mail lub hasha hasła spowoduje błąd testu.
Szukanie słowa password w surowym JSON jest słabym zamiennikiem. Nazwa pola może się zmienić, a publiczny artykuł może legalnie zawierać to słowo. Wyszukiwanie konkretnej tajnej wartości z danych testowych bywa dodatkowym zabezpieczeniem, ale znany obiekt publiczny sprawdzaj przede wszystkim strukturalnie.
toHaveKeys() dopuszcza dodatkowe pola; dokładne porównanie kluczy świadomie je wyklucza. Wybór wynika z kontraktu. Porównanie całego surowego JSON bywa nadmiernie wrażliwe na białe znaki i kolejność kluczy, choć sprawdza się przy celowo zamrożonej odpowiedzi lub pliku wzorcowym. Same klucze nie wystarczą: wartości i typy również muszą być poprawne.
Odrzucaj błędne dane i utrzymuj przewidywalne błędy
Oddziel niepoprawne dane biznesowe od treści, której nie da się zdekodować. W tym przykładzie walidacja pól zwraca 422, uszkodzony JSON — 400, a nieobsługiwany typ treści żądania — 415. To jawne decyzje dla przykładowego API, a nie uniwersalne ustawienia Symfony. Test powinien wymagać statusów udokumentowanych w danej aplikacji.
it('odrzuca niepoprawne zgłoszenie bez zapisu', 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']],
]);Dataset obejmuje puste oraz brakujące wymagane pola. contactRequestCount() to przykładowy helper odczytujący bazę, a tabela zgłoszeń jest początkowo pusta. Test sprawdza odrzucenie żądania i brak zapisu. Jeżeli istotnym ryzykiem jest wysłanie e-maila, wywołanie dostawcy lub dispatch wiadomości, sprawdź brak właśnie tego efektu. Nie każdy test walidacji musi obserwować wszystkie zależności.
it('odrzuca uszkodzony 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('odrzuca nieobsługiwany typ treści', 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);
});Brak Content-Type i nieakceptowalny nagłówek Accept to osobne przypadki. Pierwszy opisuje przesłaną treść, drugi — akceptowane reprezentacje odpowiedzi. Sprawdź udokumentowane zachowanie endpointu, w tym 406, jeśli wynika z negocjacji treści. Nie zakładaj, że każde API JSON stosuje tę samą politykę.
Publiczny błąd powinien mieć stabilne znaczenie bez ujawniania klas wyjątków, stosu wywołań, SQL, ścieżek plików ani danych uwierzytelniających. Dla angielskiej trasy artykułu może wyglądać tak:
{
"error": {
"code": "post_not_found",
"message": "The requested article was not found.",
"details": []
}
}Wewnętrzny kontekst diagnostyczny należy do chronionych logów. Publiczny kod i bezpieczny komunikat sprawdzaj niezależnie:
it('zwraca publiczny błąd braku artykułu', 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('odrzuca DELETE na publicznej trasie odczytu', function (): void {
$client = static::createClient();
requestJson($client, 'DELETE', '/api/en/posts/example');
self::assertResponseStatusCodeSame(405);
});Drugi test zakłada istniejącą trasę publicznego odczytu bez obsługi DELETE. Przy kontrakcie 405 można też sprawdzić Allow, aby wykryć niepoprawnie deklarowane metody. Testuj nagłówki wpływające na klienta, takie jak typ JSON sprawdzany przy liście, zamiast utrwalać wszystkie nagłówki generowane przez framework.
Sprawdź odmowę i dozwoloną operację
Uwierzytelnienie ustala tożsamość klienta. Autoryzacja rozstrzyga, czy wolno mu wykonać operację. Zgodnie z typową semantyką HTTP 401 wskazuje brak prawidłowych danych uwierzytelniających, a 403 — odmowę wykonania żądania. Samo 403 nie dowodzi, że klient został uwierzytelniony. Na odpowiedź wpływają entry point, przekierowania i obsługa wyjątków, dlatego najpierw ustal kontrakt API.
W naszym przykładzie anonimowy klient JSON dostaje 401:
it('odrzuca anonimowy dostęp do aktywności administracyjnej', function (): void {
$client = static::createClient();
requestJson($client, 'GET', '/api/admin/activity');
self::assertResponseStatusCodeSame(401);
});Przy publikacji sprawdź rozpoznanego użytkownika bez uprawnienia oraz administratora uprawnionego do operacji. Artykuł należy do testowanego użytkownika, więc własność jest kontrolowana; zmienną jest rola:
it('egzekwuje uprawnienie do publikacji', 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() oznacza przykładowy helper wystawiający prawidłowe testowe poświadczenie dla wskazanej tożsamości i zwracający nagłówek HTTP_AUTHORIZATION. Musi używać przewidzianego mechanizmu uwierzytelniania. reloadPost() po żądaniu ponownie odczytuje zapisany stan. Żadnej z tych metod nie dostarcza Pest. Kontrakt przykładu pozwala administratorowi publikować i zwraca 204; ograniczenia dotyczące tenanta, zespołu czy stanu biznesowego wymagają własnych przypadków.
Dla firewalla stanowego właściwym skrótem testowym może być Symfony loginUser() z odpowiednim kontekstem bezpieczeństwa. Nie uwierzytelni on żądania w firewallu bezstanowym: tam przekazuj wymagane poświadczenie przy każdym żądaniu. Walidację poświadczeń sprawdzaj osobno, jeśli helper ją omija. Nie wyłączaj zabezpieczenia tylko po to, by test odmowy lub sukcesu przeszedł.
Reset hasła chroni inną granicę: odpowiedź nie powinna bez potrzeby ujawniać, czy konto istnieje. Przy odizolowanej bazie użytkowników zawierającej wyłącznie przygotowane konto i limiterze dopuszczającym oba żądania:
it('zachowuje neutralną odpowiedź resetu hasła', 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);
});Przykładowy kontrakt zwraca 202 i ten sam neutralny komunikat w obu przypadkach. Zgodność tych obserwacji ogranicza konkretne ujawnienia; nie wyklucza różnic w czasie, nagłówkach czy innych kanałach. Zwykłe porównania czasu w CI są zbyt niestabilne, by dowodzić odporności na ustalanie istnienia kont tą metodą.
Lokalizacja i paginacja to reguły wyboru danych
Lokalizowana trasa może zwrócić 200, a mimo to wybrać niewłaściwy rekord. Nadaj danym testowym jawne slugi i porównaj odpowiedź z niezależnymi oczekiwaniami. Nie wyprowadzaj oczekiwanego sluga z tego samego mechanizmu odczytu, który sprawdzasz:
it('zwraca żądane tłumaczenie', 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() zapisuje tutaj jeden opublikowany artykuł z dokładnie tymi tłumaczeniami. Tytuły i identyfikatory danych testowych pozostają jednakowe we wszystkich wersjach językowych artykułu. Dodaj brak tłumaczenia oraz nieobsługiwaną lokalizację zgodnie z przyjętą polityką: 404, udokumentowany język zastępczy i przekierowanie to różne kontrakty. Test fallbacku musi sprawdzać, jaki język faktycznie zwrócono. Zweryfikuj też, czy odpowiedź nie zawiera przypadkiem pozostałych tłumaczeń.
Paginacja potrzebuje stabilnego sortowania, również reguły rozstrzygającej kolejność przy jednakowych datach. Zwykły przypadek strony i warianty niepoprawnych parametrów warto trzymać razem:
it('zwraca żądaną stronę', 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('odrzuca niepoprawną paginację', 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',
]);Ten przykład dopuszcza page >= 1 i 1 <= limit <= 100, a poza zakresem zwraca 400. Inne API może ograniczać wartość do maksimum albo zwracać błąd walidacji. Testuj udokumentowaną decyzję. Dataset ma sens, ponieważ zmieniają się wyłącznie dane wejściowe, a oczekiwany wynik pozostaje ten sam.
Test liczby elementów nie sprawdza kolejności. Przy ustalonych datach porównaj również oczekiwane ID na sąsiednich stronach, wyklucz nakładanie się wyników i sprawdź stronę za końcem zbioru. Zapytanie łączące tłumaczenia lub tagi zasługuje na przypadek ujawniający powielone artykuły albo zawyżoną liczbę wyników.
Zapytania sprawdzaj z bazą, decyzje — na obiektach
Jeżeli ryzyko dotyczy DQL, złączeń lub mapowania Doctrine, mock QueryBuilder go nie sprawdzi. Ten przykładowy antywzorzec jedynie konfiguruje wywołania zwracające ten sam obiekt:
$queryBuilder = $this->createMock(\Doctrine\ORM\QueryBuilder::class);
$queryBuilder->method('select')->willReturnSelf();
$queryBuilder->method('join')->willReturnSelf();
$queryBuilder->method('where')->willReturnSelf();Taka atrapa może pomóc w wąskim teście współpracy obiektów, ale nie potwierdzi poprawnego wyboru rekordów. Priorytetowo traktuj testy integracyjne złożonych i istotnych zapytań, używając odizolowanej bazy o zgodnym silniku i schemacie:
it('znajduje tylko opublikowane artykuły we wskazanym języku', 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() pobiera rzeczywistą implementację Doctrine; helpery danych wykonują flush() przed zapytaniem. Repozytorium przykładu zwraca model odczytu artykułu albo null, bez zastępczego języka. Tam, gdzie ma to znaczenie, wyczyść stan zarządzanych encji, aby wcześniej wczytany obiekt nie udawał dowodu świeżego odczytu. Nie każda trywialna metoda repozytorium wymaga osobnego testu integracyjnego.
Usługę mapującą model odczytu na wynikowy DTO można sprawdzić bez HTTP i bez kernela:
it('mapuje opublikowany artykuł na lokalizowany 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, interfejs repozytorium i GetPublishedPostService to przykładowe typy domenowe. Builder musi zwracać typ zadeklarowany przez findPublishedBySlug(), a oczekiwany DTO odpowiadać kontraktowi get(). Używamy istniejących mocków PHPUnit. W tym repozytorium nie ma Mockery; nie trzeba instalować go dla tej asercji.
Oddziel przyjęcie pracy od jej wykonania
Dla POST /api/en/contact wartościowy test HTTP potwierdza zapis zgłoszenia i wysłanie właściwej wiadomości potwierdzającej do transportu. Nie musi uruchamiać workera ani wysyłać prawdziwego e-maila. Fragment zakłada, że środowisko testowe kieruje już SendContactConfirmation do transportu w pamięci o nazwie async:
use Symfony\Component\Messenger\Transport\InMemory\InMemoryTransport;
it('zapisuje zgłoszenie i wysyła wiadomość potwierdzającą', 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() należy do Symfony i zwraca koperty wiadomości. Typ wiadomości oraz publiczne pole contactRequestId należą do przykładowej aplikacji, a findContactRequestByEmail() odczytuje bazę testową. Pobierz transport po utworzeniu klienta, zacznij z pustym magazynem i ogranicz ten test do jednego żądania. Przy wielu żądaniach restart kernela i reset usług wpływają na obserwowany stan transportu.
Metody messengerTransport() czy queue()->assertCount() nie są ogólnym API Symfony/Pest. Jeśli projekt je udostępnia, nazwij tę zależność. Sam dispatch do pamięci nie testuje też rzeczywistego brokera ani koniecznie jego ścieżki serializacji; te granice sprawdzaj osobno, jeśli są istotne.
Handler otrzymuje własny test:
it('wysyła potwierdzenie i zapisuje wynik', 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);
});Atrapy i createHandler() oznaczają tu przykładową infrastrukturę testową. Helper musi podłączyć repozytorium zawierające przekazane zgłoszenie, któremu builder nadaje stałe ID, oraz dokładnie te obiekty wysyłki i raportów, które widać w teście. Asercje efektów są miarodajne, bo sprawdzają te same instancje. Dodaj przypadki ponownego dostarczenia wiadomości, gdy mogłoby ono ponownie wysłać e-mail lub wykonać operację biznesową.
Nie utożsamiaj obsługi awarii z rollbackiem i idempotencją
Atrapa repozytorium zgłaszająca błąd nadaje się do testu ścieżki awarii usługi:
it('zgłasza błąd zapisu zamówienia', 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 i fabryka usługi są przykładowe. Jeżeli atrapa rzuca wyjątek przed zachowaniem zamówienia, pusta kolekcja niczego nie dowodzi o rollbacku Doctrine. Test rzeczywistej transakcji musi uruchomić granicę transakcyjną aplikacji na bazie testowej: wymusić błąd po rzeczywistym zapisie i świeżym odczytem sprawdzić brak częściowego zamówienia oraz powiązanego wpisu outbox. Zewnętrzna transakcja testowa wycofywana w sprzątaniu może ukryć brak rollbacku aplikacji. Asercję wykonaj przed sprzątaniem i dobierz izolację pozwalającą badać rzeczywiste zatwierdzanie transakcji.
Idempotencja wymaga osobnego testu. W tym kontrakcie API odtwarza pierwotną odpowiedź 201 dla tego samego klienta, klucza idempotencji i logicznie identycznej treści:
it('odtwarza odpowiedź bez tworzenia drugiego zamówienia', 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);
});Helpery klienta i produktu zapisują prawidłowe dane testowe; klient jest tutaj uwierzytelnianą tożsamością, a zbiór zamówień początkowo jest pusty. Test sprawdza sukces, ten sam identyfikator i jedno zapisane zamówienie. Samo porównanie statusów zaakceptowałoby również dwie awarie. Jeżeli kontrakt przy powtórzeniu przewiduje 200, wymagaj tego jawnie. Ponowne użycie klucza z inną treścią wymaga osobnego testu polityki konfliktów.
To test kolejnych prób wykonywanych sekwencyjnie. Nie dowodzi bezpieczeństwa przy współbieżnych żądaniach. Gdy takie duplikaty są ryzykiem, sprawdź konkurujące żądania na rzeczywistym mechanizmie, np. ograniczeniu unikalności i atomowym zajęciu operacji. Przy efekcie zewnętrznym uwzględnij także mechanizm idempotencji dostawcy.
Nadaj awariom zewnętrznym i cache konkretny kontrakt
Awaria GeoIP może pozostawić opcjonalny kraj nieznany, a mimo to pozwolić zapisać aktywność. To decyzja przykładowego produktu, nie zasada ignorowania dowolnej awarii dostawcy:
final class FailingGeoIpResolver implements GeoIpResolverInterface
{
public function resolve(string $ip): GeoIpResult
{
throw new GeoIpUnavailable();
}
}
it('zapisuje aktywność bez kraju przy awarii 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();
});Interfejs resolvera, wynik, wyjątek i helper recordedActivities() to przykładowe typy projektu. Test zaczyna z pustym zbiorem aktywności i zakłada synchroniczny zapis jednej aktywności podczas żądania. Kontener testowy musi umożliwiać zastąpienie resolvera przed utworzeniem jego odbiorcy. Podmień go po uruchomieniu kernela przez createClient() i zachowaj izolację między testami. Zwykłe testy automatyczne powinny korzystać z kontrolowanych odpowiedzi dostawcy; celowe testy jego kontraktu prowadź osobno.
Przy cache ustal, co ma być obserwowalne. Jeśli ograniczenie odczytów repozytorium jest jawnym wymaganiem, sprawdzenie liczby wywołań ma uzasadnienie:
it('ponownie wykorzystuje ustawienia z cache dla tego języka', 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);
});Fabryka usługi i cache w pamięci są przykładowe. Test dowodzi ponownego użycia wartości w tej implementacji i czasie życia cache. Nie sprawdza adaptera Redis ani poprawności unieważniania. Osobny przypadek integracyjny powinien wypełnić cache ustawień EN i DE, zmienić angielski slogan ścieżką zapisu administratora, a następnie potwierdzić świeżą wartość EN i dostępność DE bez ponownego wczytania — jeżeli taka jest polityka unieważniania projektu. Szersze unieważnianie wymaga innych oczekiwań.
Sprawdzaj limity w kontrolowanych warunkach
Test limitera potrzebuje świeżego, odizolowanego magazynu stanu i kontrolowanego źródła czasu. Stan musi przetrwać żądania wewnątrz testu, również restarty kernela klienta, lecz zostać usunięty lub odseparowany między testami i równoległymi procesami. Poniższy scenariusz dopuszcza pięć zgłoszeń na IP w jednym oknie; wszystkie sześć prób mieści się w tym oknie, a inne zabezpieczenie nie odrzuca poprawnej treści:
it('ogranicza kolejne zgłoszenia kontaktowe', 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);
});Pięć to próg przykładowy. Liczba zapisów potwierdza dodatkowo, że odrzucona próba nie utworzyła zgłoszenia. Wygaśnięcie okna sprawdzaj mechanizmem zegara testowego wspieranym przez daną implementację limitera. Nie dodawaj rzeczywistego sleep() i nie zakładaj, że dowolna atrapa zegara steruje jego wewnętrznym czasem.
Budżet zapytań pomaga przy endpointach podatnych na regresję N+1:
it('ogranicza liczbę zapytań aplikacji dla strony listy', 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() jest przykładowym helperem kolektora, nie metodą Symfony. Musi obserwować połączenie Doctrine faktycznie używane przez żądanie, także po restarcie kernela. Pomiar wyklucza przygotowanie danych, start kontenera, testowe logowanie i niezwiązane zapytania frameworka. Granica czterech zapytań jest przykładem dla konkretnego projektu. Różne liczby rekordów pomagają wykryć wzrost, ale mała liczba zapytań nie dowodzi niskiego opóźnienia ani dobrego planu wykonania.
Limit rozmiaru odpowiedzi wykrywa inną klasę regresji:
it('ogranicza rozmiar odpowiedzi listy publicznej', 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);
});Próg 300_000 bajtów dotyczy nieskompresowanej treści tego przykładu i reprezentatywnych, deterministycznych danych. Nie jest ogólnym limitem API. Może wykryć pełne treści artykułów, wszystkie tłumaczenia albo nieoczekiwanie rozbudowany graf encji. Zachowaj również asercje strukturalne: mała odpowiedź też może ujawniać sekret.
Kontroluj dane, czas i izolację
Używaj stałych adresów, np. client@example.test, jawnych slugów, lokalizacji, cen, ID operacji i identyfikatorów dostawców wszędzie tam, gdzie wpływają na asercje. Losowe dane mogą służyć szerszej eksploracji, ale błąd musi dać się odtworzyć. Mały builder powinien ujawniać istotny stan:
$post = PostBuilder::new()
->published()
->translated(
locale: 'en',
title: 'Testing Symfony APIs with Pest',
slug: 'testing-symfony-apis-with-pest',
)
->build();Ten PostBuilder jest przykładowy. build() tworzy obiekt, a jego zapis stanowi osobny krok przygotowania danych. Nie ukrywaj publikacji, własności ani tenanta w domyślnych wartościach, jeśli rozstrzygają o wyniku.
Korzystaj z abstrakcji czasu już przyjętej w projekcie. Symfony MockClock może zastąpić zgodną zależność zegara, w tym Psr\Clock\ClockInterface, bez tworzenia własnego 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');Fragment pokazuje wyłącznie kontrolę czasu. Wstrzyknij ten zegar do testowanej usługi przed sprawdzaniem dat publikacji, ważności tokena lub TTL cache. Przesuwanie niepowiązanego lokalnego zegara nie zmieni zachowania tych usług.
Izolację bazy można uzyskać przez rollback każdego testu, reset bazy, odtworzenie schematu albo kontrolowane ładowanie danych. Rollback bywa szybki, ale nie izoluje zapisów na niezależnych połączeniach ani w zewnętrznych systemach. Reset lub odtworzenie bazy kosztuje więcej, za to pozwala badać zachowanie po zatwierdzeniu. Dobierz strategię świadomie, rozdziel stan procesów równoległych i resetuj też kolejki, cache oraz limitery. Używaj danych syntetycznych, nigdy produkcyjnych; wynik nie powinien zależeć od kolejności testów.
Daj analizie statycznej i schematom właściwe zadania
Testy architektury Pest mogą egzekwować wybrane reguły zależności. To repozytorium zawiera plugin architektury dla Pest 3, a poniższa składnia jest przez niego obsługiwana:
arch('kontrolery API nie korzystają bezpośrednio z Doctrine')
->expect('App\Api\Controller')
->not->toUse('Doctrine\ORM\EntityManagerInterface');
arch('warstwa Core nie zależy od obsługi HTTP')
->expect('App\Core')
->not->toUse('Symfony\Component\HttpFoundation');Przestrzenie nazw i ograniczenia opisują możliwe reguły projektu. Nie są wymaganiami Symfony ani dowodem, że to repozytorium już je spełnia. Przyjmuj uzgodnione reguły i rzeczywiste przestrzenie nazw. W tym repozytorium nie trzeba instalować dodatkowego pluginu.
PHPStan powinien analizować helpery testowe razem z kodem aplikacji. Adnotacje requestJson() opisują wejście; responseJson() uczciwie deklaruje tablicę jeszcze niezweryfikowanych wartości. Rozbudowany kształt tablicy w PHPDoc nie waliduje JSON podczas wykonania. Zawężaj typy przez sprawdzenia lub korzystaj z istniejącego, walidowanego DTO odpowiedzi, zamiast deklarować typ, którego helper nie potwierdził. Analiza statyczna uzupełnia wykonywane testy kontraktu.
Jeżeli API ma już dokument OpenAPI lub równoważny schemat, sprawdzaj reprezentatywne odpowiedzi istniejącym narzędziem walidacji. Wykryje to brak pól, błędne typy i naruszenia opisanych ograniczeń. Nie potwierdzi uprawnień, prawidłowego wyboru rekordów ani braku zabronionych efektów. Sama obecność bundle’a dokumentacyjnego nie oznacza, że każda trasa ma użyteczny schemat.
Kompatybilność wsteczna również jest obserwowalna. Na przykład:
{
"id": 42,
"status": "published",
"publishedAt": "2026-09-17T09:00:00+00:00"
}Zmiana publishedAt z tego ciągu ISO 8601 na timestamp Unix zmienia kontrakt, nawet gdy obie wartości oznaczają tę samą chwilę. Zachowaj wąską asercję zgodności dla formatów, od których zależą klienci.
Ułóż plan dla jednego endpointu
Dla POST /api/{locale}/contact przypisałbym testy do błędów, które mają wykryć:
Zachowanie kontaktu Odpowiedzialność testu
Normalizacja i decyzje Jednostkowy: właściwe dane wynikowe
Zapis i ograniczenia Integracyjny: rzeczywista baza
Metoda, JSON, walidacja API: przyjęcie lub odrzucenie
Dostęp i ochrona nadużyć API: granica zabezpieczeń
Dispatch potwierdzenia API: wiadomość i ID zgłoszenia
Obsługa potwierdzenia Handler: wysyłka i raport
Publiczna odpowiedź API: status, pola, bezpieczne błędyTo podział odpowiedzialności, nie nakaz powielania każdego wiersza w trzech zestawach. Zacznij od przyjętego żądania, a awarie dobierz do faktycznych ryzyk endpointu. Wcześniejsze przykłady dostarczają potrzebnych elementów bez zamieniania testu kontaktu w wykład o całym Doctrine, Security i Messengerze.
CI powinno szybko zwracać wynik kontroli składni i analizy statycznej, a dalej obejmować testy jednostkowe, integracyjne i funkcjonalne API oraz wybrane kontrole architektury, zgodności czy E2E. Niezależne zadania mogą działać równolegle; kolejność i ścieżki zestawów zależą od repozytorium. Ten projekt udostępnia przez Docker następujące skrypty Composera:
docker compose exec -T web composer analyse
docker compose exec -T web composer testPierwsze polecenie uruchamia skonfigurowaną analizę statyczną, drugie — zestaw testów projektu. Do wąskiej zmiany wybierz istniejące pliki lub grupy testów. Przykładowa struktura nie dowodzi istnienia katalogu tests/Unit.
Pracując z asystentem AI, przekaż mu aktualny kontrakt i reguły bezpieczeństwa przed zleceniem testów. Sprawdź, czy asercje chronią paginację, efekty uboczne oraz operacje dozwolone i odrzucane. Uruchom właściwe testy i analizę statyczną; wiarygodnie wyglądający kod nie jest weryfikacją.
Dobry test API pozwala sprawdzić konkretną obietnicę. Wybierz najprostszy poziom, na którym jej naruszenie rzeczywiście wywoła błąd, i pozostaw tyle testów HTTP, by potwierdzić, że endpoint prawidłowo łączy sprawdzane zachowania.
