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ł
limeisfTestFunctional/sfBrowser. Dziś standardem jestWebTestCase(nasymfony/browser-kit+symfony/dom-crawler), z pomocnikami takimi jaksubmitForm()iloginUser(). 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 (atrybutname) 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 przyWebTestCase— 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:
- ✅
WebTestCasei 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łówekAuthorization: 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.