16

Dzień 16 z 18

Testy funkcjonalne

Opublikowany

Sprawdzamy aplikację tak, jak robi to przeglądarka — wysyłamy żądania HTTP, wypełniamy formularze i weryfikujemy odpowiedzi (również JSON z API). Poznamy WebTestCase (Client, Crawler, Response), submitForm(), loginUser() oraz testowanie endpointów API z tokenem.

Czego się nauczysz

  • WebTestCase — Client, Crawler, Response
  • Testowanie stron: asercje statusu i treści
  • Testowanie formularzy przez submitForm() (sukces i błąd 422)
  • Testowanie autoryzacji przez loginUser() (redirect / 403 / 200)
  • Testowanie API: jsonRequest, toArray, nagłówek Authorization
  • Uruchamianie testów funkcjonalnych i izolacja przez DAMA

Co zmieniło się w Symfony 8 vs 4.2

  • WebTestCase (browser-kit + dom-crawler) zamiast lime/sfBrowser
  • submitForm() z automatycznym tokenem CSRF zamiast ręcznego POST
  • loginUser() zamiast ręcznego budowania sesji z tokenem
  • Symfony Panther dostępny opcjonalnie do testów E2E w przeglądarce

Dzień 16: Testy funkcjonalne

W Dniu 15 testowaliśmy klasy w izolacji. Dziś sprawdzimy aplikację tak, jak robi to przeglądarka — wyślemy żądania HTTP, klikniemy linki, wypełnimy formularze i zweryfikujemy odpowiedzi (również JSON z API). Służy do tego WebTestCase — bazowa klasa Symfony uruchamiająca prawdziwe jądro aplikacji.

Zmiana vs Symfony 4.2: oryginał używał lime i sfTestFunctional / sfBrowser. Dziś standardem jest WebTestCase (na symfony/browser-kit + symfony/dom-crawler), z pomocnikami takimi jak submitForm() i loginUser(). Izolację bazy zapewnia DAMA\DoctrineTestBundle (transakcja z rollbackiem — jak w Dniu 15).


WebTestCase — Client, Crawler, Response

Test funkcjonalny działa na trzech obiektach:

  • Client — symuluje przeglądarkę: wysyła żądania (request()), przechowuje sesję i ciasteczka.
  • Crawler — obiekt zwracany przez request(); pozwala przeszukiwać DOM selektorami CSS/XPath.
  • Response — odpowiedź HTTP; sprawdzamy ją asercjami (assertResponse*, assertSelector*).

Szkielet testu — tests/Functional/Controller/JobControllerTest.php:

<?php

namespace App\Tests\Functional\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;

final class JobControllerTest extends WebTestCase
{
    public function testHomepageLoads(): void
    {
        $client = static::createClient();
        $client->request('GET', '/');

        $this->assertResponseIsSuccessful();          // status 2xx
        $this->assertSelectorTextContains('h1', 'Jobeet');
    }
}

static::createClient() uruchamia jądro w środowisku test. Uwaga: klienta tworzymy raz na test, przed pierwszym żądaniem.

Zmiana vs Symfony 4.2: dawny sfBrowser łączył request i asercje w jednym łańcuchu ($b->get('/')->isStatusCode(200)). Dziś mamy osobno Client (żądanie), Crawler (DOM) i zestaw statycznych asercji PHPUnit z czytelnymi komunikatami.


Testowanie stron i asercje

Najczęściej sprawdzamy status odpowiedzi i obecność treści. Utwórzmy dane testowe i zweryfikujmy listę ofert:

public function testJobListShowsActiveJob(): void
{
    $client = static::createClient();
    $job = $this->createJob(); // helper tworzący aktywną ofertę w bazie testowej

    $client->request('GET', '/');

    $this->assertResponseIsSuccessful();
    $this->assertSelectorTextContains('body', $job->getPosition());
}

Najprzydatniejsze asercje:

Asercja Sprawdza
assertResponseIsSuccessful() status 2xx
assertResponseStatusCodeSame(404) konkretny kod
assertResponseRedirects('/login') przekierowanie pod dany adres
assertSelectorTextContains('h1', 'X') tekst w elemencie (selektor CSS)
assertSelectorExists('a.btn') istnienie elementu
assertSelectorTextNotContains(...) brak tekstu

Ofertę wygasłą lub nieistniejącą powinien witać 404:

public function testExpiredJobReturns404(): void
{
    $client = static::createClient();
    $job = $this->createJob(expiresAt: new \DateTimeImmutable('-1 day'));

    $client->request('GET', '/job/' . $job->getToken());

    $this->assertResponseStatusCodeSame(404);
}

Testowanie formularzy

submitForm() znajduje formularz po tekście przycisku, wypełnia pola i wysyła go. Sprawdźmy dodawanie oferty (historia z Dnia 8):

public function testPostJobWithValidData(): void
{
    $client = static::createClient();
    $category = $this->createCategory();

    $client->request('GET', '/job/create');
    $client->submitForm('Wystaw ofertę', [
        'job[position]'    => 'Programista PHP',
        'job[company]'     => 'ACME',
        'job[location]'    => 'Wrocław',
        'job[email]'       => 'praca@example.com',
        'job[description]' => 'Szukamy programisty PHP.',
        'job[category]'    => $category->getId(),
    ]);

    // po sukcesie następuje przekierowanie na podgląd (po tokenie)
    $this->assertResponseRedirects();

    $repo = static::getContainer()->get(JobRepository::class);
    $this->assertNotNull($repo->findOneBy(['position' => 'Programista PHP']));
}

Formularz z błędami walidacji nie przekierowuje — Symfony renderuje go ponownie ze statusem 422:

public function testPostJobWithEmptyPositionShowsError(): void
{
    $client = static::createClient();
    $client->request('GET', '/job/create');

    $client->submitForm('Wystaw ofertę', [
        'job[position]' => '',
        'job[company]'  => 'ACME',
        'job[email]'    => 'praca@example.com',
    ]);

    $this->assertResponseStatusCodeSame(422);
}

Nazwy pól (job[position]) wynikają z nazwy formularza Symfony. Podejrzysz je w HTML (atrybut name) albo w profilerze. submitForm() sam dołącza token CSRF z formularza.


Testowanie autoryzacji

Strony admina (z Dnia 11) wymagają logowania. Zamiast przechodzić przez formularz /login, użyjemy pomocnika loginUser(), który uwierzytelnia użytkownika w sesji testowej:

public function testAdminRequiresLogin(): void
{
    $client = static::createClient();
    $client->request('GET', '/admin/jobs');

    $this->assertResponseRedirects('/login');
}

public function testAdminForbiddenForRegularUser(): void
{
    $client = static::createClient();
    $client->loginUser($this->createUser(roles: []));   // zwykły ROLE_USER

    $client->request('GET', '/admin/jobs');

    $this->assertResponseStatusCodeSame(403);
}

public function testAdminAccessibleForAdmin(): void
{
    $client = static::createClient();
    $client->loginUser($this->createUser(roles: ['ROLE_ADMIN']));

    $client->request('GET', '/admin/jobs');

    $this->assertResponseIsSuccessful();
}

Trzy przypadki pokrywają całą logikę dostępu: niezalogowany → redirect na /login, zły rola → 403, admin → 200.

Zmiana vs Symfony 4.2: dawniej trzeba było ręcznie budować sesję z tokenem uwierzytelnienia. Symfony daje $client->loginUser($user) — jedna linia, bez przechodzenia przez formularz logowania.


Testowanie API (JSON)

Jobeet ma też API (NelmioApiDoc + DTO). Do żądań JSON używamy jsonRequest(), a odpowiedź odczytujemy metodą ->toArray():

public function testJobsApiReturnsJson(): void
{
    $client = static::createClient();
    $this->createJob();

    $client->request('GET', '/api/jobs');

    $this->assertResponseIsSuccessful();
    $this->assertResponseHeaderSame('Content-Type', 'application/json');

    $data = $client->getResponse()->toArray();
    $this->assertNotEmpty($data);
}

Endpoint chroniony tokenem (z rozdziału o uwierzytelnianiu API) testujemy, dołączając nagłówek Authorization:

public function testAdminApiRequiresToken(): void
{
    $client = static::createClient();
    $client->request('GET', '/api/admin/jobs');

    $this->assertResponseStatusCodeSame(401);
}

public function testAdminApiWithValidToken(): void
{
    $client = static::createClient();
    $user = $this->createUser(roles: ['ROLE_ADMIN'], apiToken: 'test-token-123');

    $client->request('GET', '/api/admin/jobs', server: [
        'HTTP_AUTHORIZATION' => 'Bearer test-token-123',
    ]);

    $this->assertResponseIsSuccessful();
}

Nagłówki HTTP przekazujemy w tablicy server z prefiksem HTTP_ (konwencja PHP: HTTP_AUTHORIZATION → nagłówek Authorization).


Uruchamianie

Testy funkcjonalne żyją w tym samym katalogu tests/ i uruchamiają się razem z jednostkowymi:

make tests_unit                                              # wszystkie testy
docker compose exec app php bin/phpunit tests/Functional     # tylko funkcjonalne
docker compose exec app php bin/phpunit --filter testAdminRequiresLogin

Pamiętaj o bazie testowej (make db-test) — testy funkcjonalne realnie zapisują i czytają dane (choć DAMA cofa je po każdym teście).

Panther (opcjonalnie): do testów E2E w prawdziwej przeglądarce (JavaScript, kliknięcia) istnieje symfony/panther. W tym projekcie zostajemy przy WebTestCase — jest szybszy i wystarcza do testów warstwy HTTP. Panther warto dołożyć dopiero, gdy testujesz zachowania zależne od JS.


Podsumowanie

Jobeet ma pełną siatkę testów — od jednostki po żądanie HTTP:

  • ✅ WebTestCase i trójka Client / Crawler / Response,
  • ✅ testowanie stron: asercje statusu (assertResponseIsSuccessful, ...StatusCodeSame) i treści (assertSelectorTextContains),
  • ✅ testowanie formularzy przez submitForm() (sukces → redirect, błąd walidacji → 422),
  • ✅ testowanie autoryzacji przez loginUser() (redirect / 403 / 200),
  • ✅ testowanie API: jsonRequest/toArray, nagłówek Authorization: Bearer,
  • ✅ uruchamianie razem z testami jednostkowymi (make tests_unit), izolacja przez DAMA.

W Dniu 17 zajmiemy się cache — Symfony Cache, HTTP Cache (ETag, Expires), tagowanie i inwalidacja w celu przyspieszenia Jobeet.

Narzędzia / paczki

WebTestCase symfony/browser-kit symfony/dom-crawler

Spis treści