Soft

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.

By Damian Ostraszewski

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 E2E

Test 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|null $payload
 * @param array $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 */
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 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 '192.0.2.10', ]); self::assertResponseStatusCodeSame($attempt 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łędy


To 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 test


Pierwsze 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.