· Engineering · 18 min czytania
7× szybsze testy E2E: jak problem z sesjami Laravel udawał niestabilne testy Playwright
Jak problem z architekturą sesji udający niestabilne testy Playwright doprowadził do paraliżu CI — i jak izolacja sesji per-worker, seed API i kilka zmian konfiguracyjnych to naprawiły.

Jeśli dopiero zaczynasz z Playwrightem lub chcesz zrozumieć podstawy testów E2E, artykuł który napisałem dla Rocksoft — End-to-end testing with Playwright — opisuje fundamenty. Ten artykuł zaczyna się tam, gdzie tamten kończy: to case study budowania równoległego zestawu testów Playwright dla aplikacji Laravel + Inertia.js i problemu z architekturą sesji, który sprawił, że całe przedsięwzięcie okazało się znacznie trudniejsze, niż powinno.
Punkt wyjścia
Aplikacja to rozbudowany dashboard — zarządzanie serwisami, klientami, pojazdami, fakturami, przepływy dla wielu użytkowników. Istniało ok. 48 plików spec z poprzedniego etapu pracy, ale suite była w kiepskim stanie:
- Brak integracji z CI
- Brak stabilnej strategii uwierzytelniania
- Brak zrównoleglenia — 1 worker
- Czas działania: ponad 34 minuty, z dużą liczbą testów pominiętych lub sporadycznie failujących
Cel: suite działająca niezawodnie w CI, kończąca się w rozsądnym czasie. Droga do tego wymagała naprawy pojedynczych testów, przebudowania strategii auth od zera i zdiagnozowania dwóch nieoczywistych wąskich gardeł infrastrukturalnych.
Struktura projektu
Zanim przejdę do tego, co się psuło, warto pokazać układ katalogów, który wyłonił się po przejściu stabilizacyjnym:
tests/e2e/
├── *.spec.ts # Specyfikacje testów (~48 plików)
├── pages/ # Klasy Page Object
│ ├── base.page.ts # Wspólna nawigacja i metody oczekiwania
│ ├── service.page.ts
│ ├── customer.page.ts
│ └── ...
├── helpers/ # Reużywalne przepływy akcji
│ ├── auth.helper.ts
│ ├── seed.helper.ts
│ ├── service-form.helper.ts
│ └── ... # 20+ helperów domenowych
├── selectors/ # Scentralizowane selektory elementów
│ ├── service.selectors.ts
│ ├── customer.selectors.ts
│ └── ...
├── fixtures/
│ ├── auth.fixture.ts # Wstrzykiwanie sesji per-worker
│ └── console-monitor.fixture.ts
├── config/
│ └── timeouts.ts
├── types/
│ └── selectors.ts
└── global-setup.ts # Uwierzytelnianie przed testami (raz na worker)Selektory
Wszystkie selektory elementów żyją w typowanych obiektach const, po jednym pliku na domenę:
// selectors/service.selectors.ts
export const ServiceSelectors = {
createButton: '#filter-action-button',
customerSelect: '#service-form-customer-select',
vehicleSelect: '#service-form-vehicle-select',
checkInButton: '#check-in-button',
navigation: {
attachments: 'serviceAttachmentsTab',
invoice: 'service-invoice-tab',
},
} as const;Projekt używa Intercom do wsparcia w aplikacji — tooltipów i product tours otwierających kontekstowe artykuły pomocy. Intercom wskazuje elementy UI przez ich atrybuty id, więc stabilne ID były już wymogiem w markupie. Selektory E2E mogły je po prostu ponownie wykorzystać bez dodawania żadnych atrybutów specyficznych dla testów. data-testid to standardowa rekomendacja Playwright, ale oznacza rozsiewanie dodatkowych atrybutów po szablonach wyłącznie na potrzeby testowania. Gdy ID są już obecne z innego powodu, to jedna rzecz mniej do utrzymania.
Page Object Model
Każdy obszar aplikacji ma własną klasę dziedziczącą po BasePage, która obsługuje wspólną nawigację i oczekiwanie na sieć. Poszczególne page objects definiują tylko swoje własne interakcje:
// pages/service.page.ts
export class ServicePage extends BasePage {
async navigate() {
await super.navigate('/services');
}
async navigateToSection(section: keyof typeof ServiceSelectors.navigation) {
const selector = ServiceSelectors.navigation[section];
await this.page.locator(`#${selector}`).click();
await this.waitForNetworkIdle();
}
}Żadnych surowych wywołań page.locator() rozrzuconych po plikach spec.
Helpery
Warto wyraźnie zarysować różnicę między Page Object a Helperem. Page Object reprezentuje stronę lub sekcję aplikacji — jest właścicielem nawigacji do tej strony i eksponuje interakcje jako metody. Helper reprezentuje reużywalny przepływ wieloetapowy, który może obejmować wiele stron lub wiele Page Objectów.
Jeśli opisujesz gdzie coś się znajduje w aplikacji — to Page Object. Jeśli opisujesz jak coś zrobić w wielu testach — to Helper.
// helpers/service-form.helper.ts
export async function fillServiceForm(page: Page, data: ServiceFormData) {
await page.locator(ServiceSelectors.customerSelect).click();
await page.locator('input').fill(data.customerName);
await page.locator('#service-form-customer-select-dropdown div').first().click();
// ...
}SeedHelper na przykład nie jest przywiązany do żadnej strony — wywołuje seed API i zwraca ID stworzonych rekordów. ServicePage to Page Object przywiązany do /services. Test komponuje oba: seed do stworzenia danych, Page Object do nawigacji, Helper do wypełnienia formularza. Każda warstwa ma jedną odpowiedzialność.
Faza 1: Sprawienie, żeby testy przechodziły
Pierwsze zadanie: sprawić, żeby 48 istniejących plików spec faktycznie przechodziło. Większość nie przechodziła — nieaktualne selektory, zmieniony routing, założenia czasowe dawno nieaktualne. Przejście stabilizacyjne naprawiało je jeden po jednym.
Ważniejsza zmiana: przepisanie testów zależnych od istniejącego stanu bazy. Jeśli test zakłada istnienie konkretnego rekordu klienta, może działać tylko po teście, który ten rekord stworzył — co wymusza sekwencyjne wykonanie. Przepisanie każdego testu tak, żeby tworzył własne dane, usuwa tę zależność. To okazało się warunkiem wstępnym zrównoleglenia: test wymagający współdzielonego stanu nie może bezpiecznie działać równolegle z innymi testami, które mogą ten stan modyfikować.
Faza 2: Problem z architekturą sesji
TL;DR: Standardowy wzorzec storageState Playwright współdzieli jedno ciasteczko sesji między wszystkimi workerami. W Laravel z sesjami opartymi na plikach, równolegle działające workery niszczą nawzajem swoje tokeny CSRF przez contention na flock(). Naprawa: uwierzytelnij N razy w global-setup.ts — raz na worker — i przypisz każdemu workerowi własny plik sesji przez testInfo.parallelIndex.
To jest fragment, który wyglądał jak problem z niestabilnymi testami — ale nim nie był.
Co zaleca dokumentacja Playwright
Standardowe podejście: jednorazowe uwierzytelnienie w global-setup.ts, zapis stanu przeglądarki do pliku, ładowanie go dla każdego testu przez storageState w playwright.config.ts:
// playwright.config.ts — standardowe podejście
use: {
storageState: 'tests/e2e/auth.json',
}To czysty wzorzec. Z jednym workerem działa perfekcyjnie.
Co się psuje z wieloma workerami
W chwili, gdy zwiększyłem liczbę workerów do 2 lub 4, testy zaczęły sporadycznie failować: strona logowania pojawiająca się w środku testu, błędy 419 CSRF, przekierowania niezwiązane z tym, co test faktycznie testował.
Przyczyna główna: SESSION_DRIVER=file w Laravel. Wszystkie workery współdzieliły to samo ciasteczko sesji z auth.json. Przy 4 workerach działających równolegle, każdy wysyłał to samo ID sesji do serwera. Laravel odczytuje plik sesji, potencjalnie go modyfikuje (regeneracja tokenów CSRF, aktualizacja flash data) i zapisuje. System operacyjny serializuje to przez flock(), ale wygrywa ostatni piszący: token CSRF zapisany przez worker 3 zostaje nadpisany przez worker 4 milisekundę później. Następne żądanie workera 3 kończy się błędem 419.
Faile były niedeterministyczne i wyglądały dokładnie jak problemy z czasem. Naturalny odruch: dodać więcej oczekiwań i retry. To nie pomogło — bo problem był na poziomie serwera, nie testów.
Co próbowałem (bez skutku)
- Więcej retry i dłuższe timeouty — maskowanie symptomów, nie naprawianie przyczyny
- Czyszczenie ciasteczek przed ponownym logowaniem — psuło CSRF
- Bezpośrednie POST do
/loginprzezpage.request—page.requestma własny jar ciasteczek niezależny od sesji przeglądarki - Różne triki z czasem logowania — niezwiązane z prawdziwym problemem
Każda poprawka celująca w kod testów goniła symptom.
Naprawa: sesje per-worker
Każdy worker potrzebuje własnej sesji — żeby żadne dwa workery nigdy nie pisały do tego samego pliku sesji po stronie serwera.
global-setup.ts loguje się teraz N razy przed startem testów — raz na skonfigurowany worker:
// global-setup.ts
const workers = config.workers ?? 4;
for (let i = 0; i < workers; i++) {
const storageFile = path.join(authDir, `worker-${i}.json`);
await loginFresh(email, password, baseUrl, storageFile);
}Każde logowanie tworzy odrębną sesję PHP na serwerze. Worker 0 trzyma sesję A, worker 1 sesję B — nigdy nie dotykają nawzajem swoich plików sesji.
Niestandardowy fixture przypisuje każdemu workerowi własny plik storage w trakcie działania przez testInfo.parallelIndex:
// fixtures/auth.fixture.ts
export const test = base.extend<{}, { workerStorageState: string }>({
workerStorageState: [async ({}, use, testInfo) => {
const storageFile = path.join(authDir, `worker-${testInfo.parallelIndex}.json`);
await use(storageFile);
}, { scope: 'worker' }],
storageState: async ({ workerStorageState }, use) => {
await use(workerStorageState);
},
});scope: 'worker' jest kluczowe — fixture inicjalizuje się raz na proces workera, nie raz na test. Pliki spec po prostu importują test z fixture:
import { test, expect } from './fixtures/auth.fixture';
test('tworzy nowy serwis', async ({ page }) => {
// page jest już uwierzytelnione — żadnego beforeEach nie potrzeba
});Żadnego boilerplate logowania. Żadnych sprawdzeń isLoggedIn(). Uwierzytelnianie jest infrastrukturą, nie kodem testów.
Jako dodatkowe zabezpieczenie CI przełącza się na sesje bazodanowe (SESSION_DRIVER=database). Sesje bazodanowe zastępują I/O pliku atomowymi operacjami SQL — żadnego flock(), żadnego “wygrywa ostatni piszący”. To zmiana tylko dla CI; produkcja i lokalne środowiska pozostają niezmienione.
Rate limiting w global-setup
global-setup.ts loguje się N razy z rzędu — raz na worker — przed startem testów. Zależnie od konfiguracji rate limitera, te szybkie kolejne żądania logowania mogą go wyzwolić. Naprawa: jedna zmienna środowiskowa w CI:
RATE_LIMIT_ENABLED=falseBezpieczne w kontrolowanym środowisku CI i eliminuje tajemnicze faile logowania niezwiązane z problemem sesji.
pressSequentially zamiast fill dla formularzy Vue + Inertia.js
To nie jest związane z problemem równoległym, ale to realny problem, który ujawnił się podczas stabilizacji logowania. fill() Playwright ustawia wartość pola bezpośrednio — bez wywoływania indywidualnych zdarzeń klawiatury. W formularzu Inertia.js obsługiwanym przez Vue v-model, fill() może wchodzić w wyścig z handlerem onblur komponentu, który resetuje stan lokalny zanim useForm Inerti zdąży odebrać zmianę. Efekt: POST /login trafia na serwer z pustym body.
Naprawa: wpisywanie znak po znaku:
await emailInput.pressSequentially(email, { delay: 10 });
await passwordInput.pressSequentially(password, { delay: 10 });
// czekaj na flush kolejki mikrotasków Vue do Inertia useForm
await page.waitForTimeout(500);pressSequentially wywołuje indywidualne zdarzenia input, które Vue v-model zatwierdza synchronicznie. Krótkie opóźnienie naśladuje realistyczne tempo pisania i eliminuje wyścig. Dotyczy każdego komponentu Vue z handlerem onblur modyfikującym stan formularza.
Faza 3: Wbudowany serwer PHP jest jednoprocesowy
Po rozwiązaniu problemu z sesjami ujawniło się kolejne wąskie gardło: php artisan serve obsługuje jedno żądanie naraz.
Przy 4 workerach Playwright wysyłających żądania równocześnie — nawigacja, wysyłanie formularzy, wywołania seed API — wszystkie te żądania ustawiają się w kolejce. Logowanie, które powinno zająć 200ms, zajmuje 2 sekundy, bo trzy inne żądania są przed nim w kolejce. Timeout Playwright odpala się. Test “failuje”.
Naprawa: jedna zmienna środowiskowa:
PHP_CLI_SERVER_WORKERS=16 php artisan serve --host=127.0.0.1 --port=8000 &Nakazuje PHP uruchomić 16 procesów workerów. 4 workery Playwright generujące skokowy ruch to już nie problem.
To ma znaczenie tylko w CI. Lokalnie Herd i Valet używają nginx + PHP-FPM, które obsługują współbieżność poprawnie.
Faza 4: Seed API — zastępowanie konfiguracji przez UI
Oryginalne testy tworzyły własne dane nawigując po UI: przejdź do klientów, kliknij “Nowy”, wypełnij formularz, zapisz, przejdź do utworzonego rekordu. To ma dwa problemy:
- Wolność. Wieloetapowy przepływ UI do tworzenia klienta może zająć 5–10 sekund zanim właściwy test się w ogóle zacznie.
- Kruchość. Jeśli UI konfiguracyjne się zepsuje, test failuje — nie dlatego, że testowana funkcja jest zepsuta.
Rozwiązanie: dedykowane seed API tworzące rekordy bezpośrednio przez PHP i zwracające ich ID.
POST /e2e/seed/customer → { id: 42, name: "E2E Test Company" }
POST /e2e/seed/service → { id: 7, customer_id: 42 }
POST /e2e/seed/invoice → { id: 19, service_id: 7 }Routy są zabezpieczone — dostępne tylko gdy APP_ENV nie jest production — i wykluczone z weryfikacji CSRF w VerifyCsrfToken.php. Testy nawigują bezpośrednio do URL utworzonego zasobu:
const seed = SeedHelper.fromPage(page);
const customer = await seed.createCustomer({ name: 'E2E Test Company' });
const service = await seed.createService({ customer_id: customer.id });
await page.goto(`/services/${service.id}`);
// test zaczyna się tutaj — żadnej nawigacji konfiguracyjnejWażny szczegół: wywołania seed używają page.evaluate(fetch) zamiast page.request Playwright. Powód: page.request ma własny jar ciasteczek niezależny od sesji przeglądarki i nie może uwierzytelnić się jako zalogowany użytkownik. page.evaluate(fetch) działa wewnątrz kontekstu przeglądarki i wysyła rzeczywiste ciasteczko sesji:
const result = await page.evaluate(async ({ url, data }) => {
const res = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
return { status: res.status, body: await res.text() };
}, { url, data });Faza 6: Zakleszczenia MySQL w równoległym PHPUnit
Uruchomienie PHPUnit z --parallel wprowadziło osobny problem po stronie backendu: zakleszczenia MySQL podczas konfiguracji testów.
Trait RefreshDatabase Laravel wywołuje migrate:fresh do resetowania schematu przed testami. Przy 8 równoległych workerach wywołujących migrate:fresh jednocześnie, operacje DDL MySQL — DROP TABLE, CREATE TABLE — na współdzielonych tabelach powodowały timeout blokad:
SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceededNaprawa: zapobiec uruchamianiu migracji przez workery w ogóle. Baza danych jest migrowana przed uruchomieniem równoległego przebiegu; workery powinny przejść bezpośrednio do transakcji.
// tests/TestCase.php
protected function setUpProcess(): void
{
RefreshDatabaseState::$migrated = true;
}setUpProcess() działa raz na proces workera. Ustawienie $migrated = true mówi RefreshDatabase, żeby pominął fazę migracji i założył, że schemat jest aktualny.
Finalna konfiguracja playwright.config.ts
Kilka wyborów konfiguracyjnych wymaga wyjaśnienia:
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: false, // pliki równolegle, testy w pliku sekwencyjnie
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 1,
workers: 4,
use: {
baseURL: process.env.E2E_TEST_URL,
// Brak globalnego storageState — każdy worker uwierzytelnia przez fixture
trace: 'on-first-retry',
},
timeout: 90000,
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
testIgnore: ['**/visual.spec.ts'],
},
{
name: 'visual',
use: { ...devices['Desktop Chrome'] },
testMatch: ['**/visual.spec.ts'],
dependencies: ['chromium'],
},
],
globalSetup: './tests/e2e/global-setup.ts',
});fullyParallel: false — Playwright może uruchamiać wszystkie testy równolegle niezależnie od pliku, albo uruchamiać pliki równolegle z testami w każdym pliku sekwencyjnie. fullyParallel: false jest bezpieczniejszym domyślnym dla złożonej aplikacji CRUD: testy w tym samym pliku spec często mają wspólny kontekst (tworzenie rekordu w jednym teście, sprawdzanie go w następnym). Zrównoleglenie na poziomie pliku daje większość korzyści prędkościowych bez wprowadzania problemów z kolejnością wewnątrz pliku.
trace: 'on-first-retry' — Trace jest rejestrowany tylko gdy test failuje i dostaje retry. To utrzymuje małe rozmiary artefaktów CI, wciąż przechwytując kompletny trace przeglądarki (sieć, konsola, screenshoty) dla każdego testu wymagającego retry.
Testy wizualne jako osobny projekt — Projekt visual uruchamia tylko visual.spec.ts i listuje chromium jako zależność, więc testy funkcjonalne zawsze działają pierwsze. Regresja wizualna strony, która jeszcze się poprawnie nie wyrenderowała, to bezwartościowy fail.
Workflow CI
Workflow wyzwala się tylko gdy zmieniają się pliki istotne dla E2E — źródła frontendowe, pliki testów, konfiguracja Playwright. Zmiany tylko backendowe nie uruchamiają drogiego suite E2E. Tylko ostatni push na branchu zajmuje runner — wcześniej uruchomione przebiegi są automatycznie anulowane.
Kilka rzeczy, które workflow musi poprawnie ustawić zanim odpali npx playwright test:
- MySQL in-memory — uruchomienie bazy przez
--tmpfseliminuje I/O jako wąskie gardło i przyspiesza setup. - Sesje bazodanowe —
SESSION_DRIVERjest przestawiany nadatabaseprzed startem serwera, jako druga warstwa izolacji sesji obok plików auth per-worker. - Rate limiting wyłączony —
global-setup.tsloguje się N razy z rzędu. Bez wyłączenia rate limitera w CI te szybkie kolejne żądania mogą go wyzwolić i zablokować fazę setupu zanim uruchomi się choćby jeden test. - Sprawdzenie gotowości serwera —
php artisan servezwraca kontrolę natychmiast, gdy serwer jeszcze się uruchamia. Krótka pętla curl przednpx playwright testupewnia się, że strona logowania jest dostępna gdyglobal-setup.tsstartuje.
Raport HTML Playwright jest uploadowany jako artefakt CI przy każdym uruchomieniu — włącznie z failami — więc zawsze jest trace-level zapis tego, co się wydarzyło.
Droga przez debugging
Droga do naprawy sesji nie była prosta. Faile produkowały symptomy wskazujące w zupełnie złe kierunki.
Jak wyglądały faile: Test nawiguje do strony, wykonuje akcję i ląduje na /login — jakby sesja wygasła w środku testu. Albo formularz zwraca 419. Albo test przechodzi lokalnie, ale konsekwentnie failuje w CI z 2+ workerami. Wygląda dokładnie jak niestabilny test albo problem z czasem.
Pierwsza intuicja — napraw testy. Dodaj więcej oczekiwań. Zwiększ timeouty. Dodaj retry. Retry pomagały nieznacznie, maskując sporadyczne faile. Przyczyna główna była nieruszona.
Druga intuicja — napraw logowanie. Może samo logowanie było kruche. Seria prób: czyszczenie ciasteczek przed logowaniem, POST bezpośrednio do /login przez page.request, różne podejścia do wypełniania formularza (fill vs pressSequentially — prawdziwy bug, niezwiązany z problemem równoległym). Żadne z nich nie naprawiło równoległych failów.
Przełom: Przeczytanie kodu źródłowego sterownika sesji Laravel i zauważenie, że SESSION_DRIVER=file zapisuje na dysk przy każdym żądaniu. Powiązanie z liczbą równoległych workerów. Hipoteza o contention na flock() wyjaśniała wszystko: dlaczego faile były losowe, dlaczego narastały przy większej liczbie workerów, dlaczego retry pomagały (retry dostaje świeżą blokadę), dlaczego nigdy nie zdarzało się lokalnie z 1 workerem.
Co faktycznie pomogło: Zamiast patrzyć na wyjście testów, spojrzeć na logi serwera. Które żądania failują? Jakie jest ID sesji w failujących żądaniach? Czy wiele workerów wysyła to samo ciasteczko sesji? Gdy te pytania miały odpowiedzi, naprawa była oczywista.
Wniosek: Gdy testy E2E failują sporadycznie i faile nie korelują z żadną konkretną logiką testów, szukaj problemów we współdzielonej infrastrukturze — przechowywaniu sesji, stanie bazy, modelu procesów serwera — zanim zaczniesz szukać w samych testach.
Bonus: uniwersalny skaner konsoli
Jednym z ostatnich dodatków do suite był spec, który odwiedza każdą stronę aplikacji i sprawdza błędy JavaScript oraz ostrzeżenia Vue w konsoli przeglądarki. Żadnych asercji o funkcjonalności — tylko “czy ta strona ładuje się bez wypluwania błędów?”
// health-check-console-scan.spec.ts
test('brak błędów konsoli na stronie serwisów', async ({ page }) => {
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
await page.goto('/services');
expect(errors).toHaveLength(0);
});Ten skaner wykrył prawdziwe regresje — brakujący klucz tłumaczenia, niezgodność typów prop Vue, nieudane ładowanie chunka — których testy funkcjonalne nie obejmowały, bo funkcja wciąż “działała” mimo szumu w konsoli.
Wyniki
| Metryka | Przed | Po |
|---|---|---|
| Workery | 1 | 4 |
| Strategia auth | 1 wspólny auth.json | Per-worker auth-N.json |
| Sterownik sesji (CI) | file | database |
| Workery serwera PHP | 1 | 16 |
| Czas działania | >34 min (wiele pominiętych/niestabilnych) | ~5 min (~140 testów) |
| Tworzenie danych testowych | Nawigacja po UI | Seed API + bezpośredni URL |
| Konflikty sesji | Tak | Nie |
Co zrobiło różnicę
Każda naprawa, w kolejności wpływu:
- Pliki auth per-worker — uwierzytelnij N razy w
global-setup.ts, jedna sesja na worker; przypisz każdemu workerowi jego plik przeztestInfo.parallelIndex - Sesje bazodanowe w CI —
SESSION_DRIVER=databaseeliminuje contention naflock()jako dodatkowe zabezpieczenie ponad izolację sesji - Workery serwera PHP CLI —
PHP_CLI_SERVER_WORKERS=16zapobiega kolejkowaniu żądań za jednoprocesowym serwerem - Rate limiting wyłączony w CI —
RATE_LIMIT_ENABLED=falsezapobiega wyzwoleniu limitera przez szybkie logowania w global-setup zanim testy się w ogóle zaczną - Seed API — zastąp konfigurację przez UI bezpośrednimi wywołaniami API; testy nawigują prosto do URL utworzonego zasobu
page.evaluate(fetch)dla uwierzytelnionych żądań — użyj kontekstu przeglądarki zamiastpage.request, żeby wysłać rzeczywiste ciasteczko sesjiRefreshDatabaseState::$migrated = true— zmigruj bazę przed równoległym przebiegiem PHPUnit; workery przechodzą prosto do transakcjifullyParallel: false— zrównoleglenie na poziomie pliku, zachowana kolejność testów wewnątrz pliku- Sprawdzenie gotowości serwera w CI — pętla curl przed
npx playwright test;php artisan servezwraca kontrolę zanim faktycznie jest gotowy do przyjmowania połączeń
Często zadawane pytania
Czy to podejście do sesji działa z Sanctum lub JWT?
Dlaczego page.evaluate(fetch) zamiast page.request dla seed API?
Czy seed API może przypadkowo zostać włączone na produkcji?
Dlaczego fullyParallel: false zamiast true?
Czy pressSequentially spowalnia testy zauważalnie?
Czy to dotyczy mnie jeśli używam Herd lub Valet lokalnie?
Testy E2E na starszym stacku — warto?
Aplikacja, którą obejmuje ten suite, nie działa na najnowszym stacku — to Vue 2 i starsza wersja Laravel. To częsty powód odkładania testów E2E: “dodamy testy po przepisaniu”.
To rozumowanie jest odwrócone. Migracja z Vue 2 do Vue 3 to dokładnie ten scenariusz, w którym solidny suite E2E się opłaca. Regresje behawioralne podczas migracji frameworka są notoryczne trudne do wykrycia testami jednostkowymi — komponenty zmieniają się wewnętrznie, podczas gdy zachowanie widoczne dla użytkownika ma pozostać takie samo. Test klikający przez formularz serwisu i weryfikujący zapisane dane nie obchodzi, która wersja Vue go renderuje. Failuje gdy zachowanie się zmienia, niezależnie od przyczyny.
34-minutowy niestabilny suite nie był użyteczny. 5-minutowy niezawodny będzie prawdziwą siatką bezpieczeństwa kiedy migracja się zacznie.
Playwright a alternatywy
Laravel Dusk to oczywisty punkt startowy dla każdego projektu Laravel — jest oficjalny, nie wymaga Node.js, a testy pisze się w PHP obok reszty aplikacji. Dla zespołów chcących zostać w ekosystemie PHP to rozsądny wybór. Koszt: działa przez ChromeDriver i protokół WebDriver, który jest wolniejszy niż podejście Playwright oparte na CDP, a zrównoleglenie wymaga więcej ręcznej konfiguracji. Warto zaznaczyć: problem izolacji sesji opisany w tym artykule dotknąłby Dusk w równym stopniu — to problem po stronie serwera Laravel, nie kwestia konkretnego frameworka testowego.
Cypress był moim narzędziem we wcześniejszych projektach. Interaktywny test runner jest naprawdę dobry i uczynił testy E2E dostępnymi w czasach, gdy alternatywy były znacznie trudniejsze w konfiguracji. Ale Cypress uruchamia testy wewnątrz procesu przeglądarki, a jego wnętrze opiera się mocno na jQuery — co oznacza, że obsługa selektorów, wysyłanie zdarzeń i pewne wzorce async nie zawsze odpowiadają temu, jak zachowuje się przeglądarka prawdziwego użytkownika. Przez długi czas był też jednozakładkowy, co utrudniało przepływy z przekierowaniami, popupami czy nowymi kontekstami. Obsługa wielu przeglądarek pojawiła się późno i wciąż sprawia wrażenie drugorzędnej. Nie wracam do niego.
Selenium / WebdriverIO to miejsce, od którego większość zespołów zaczęła — i słusznie. Selenium jest niezależny od języka, sprawdzony w boju i właściwą odpowiedzią gdy potrzebne są suity w Javie lub Pythonie. WebdriverIO to solidny wrapper Node.js, który znacząco się rozwinął. Fundamentalnym ograniczeniem pozostaje sam protokół WebDriver: każde polecenie to round-trip HTTP do sterownika przeglądarki. Playwright omija to, komunikując się przez CDP (i równoważne protokoły dla Firefox i WebKit) bezpośrednio — stąd wyższa prędkość i powód, dla którego page.evaluate czy przechwytywanie sieci działają czysto bez obejść.
Playwright jest moim obecnym domyślnym narzędziem do wszystkiego co przeglądarkowe. Jest naprawdę wieloprzeglądarkowy (Chromium, Firefox, WebKit działają dobrze), TypeScript-first, ma najlepszą historię zrównoleglenia w tej przestrzeni, a jego architektura nie idzie na kompromis — kontrolujesz przeglądarkę na poziomie protokołu. Narzędzia w tej dziedzinie rozwijają się jednak szybko. Nie jestem dogmatyczny — jeśli pojawi się coś lepszego, chętnie spróbuję.
AI w debugowaniu testów E2E
Jedno z narzędzi, które realnie zmieniło sposób w jaki diagnozuję problemy z testami: Claude Code z serwerem Playwright MCP. Zamiast analizować 300 linii wyjścia CI próbując zrekonstruować co się wydarzyło, można opisać symptom i uzyskać ukierunkowaną diagnozę — albo pozwolić AI bezpośrednio sterować przeglądarką, nawigować do failującego stanu i odczytać co konsola faktycznie pokazuje.
Dostęp do konsoli przeglądarki jest szczególnie użyteczny. Claude Code może przechwytywać na żywo wyjście konsoli — błędy, ostrzeżenia Vue, nieudane żądania sieciowe — te same sygnały, które skaner health-check zbiera automatycznie, ale interaktywnie podczas sesji debugowania. To różnica między czytaniem raportu o awarii a obserwowaniem awarii na żywo.
Jest teraz dużo szumu wokół AI piszącego testy od zera — “vibe testing”, generowanie plików spec z promptu i wypychanie ich od razu. To prowadzi dokładnie do takiego suite jak ten opisany na początku artykułu: testy które technicznie istnieją, ale nie działają niezawodnie, nie izolują stanu i failują z powodów niezwiązanych z testowaną funkcją. AI jest naprawdę dobrym partnerem do debugowania i szybkim sposobem na generowanie boilerplate’u. Myślenie inżynierskie — rozumienie dlaczego testy muszą być samodzielne, dlaczego współdzielony stan sesji psuje równoległe wykonanie, dlaczego symptomy i przyczyny to różne rzeczy — to nadal to, co sprawia że suite testów faktycznie działa.
Dokumentacja Playwright
Oficjalna dokumentacja dla wzorców opisanych w tym artykule:
- Authentication — standardowy wzorzec
storageStatei kiedy działa - Global setup and teardown —
global-setup.ts, uruchamianie kodu przed wszystkimi testami - Fixtures — rozszerzanie przez
base.extend(), fixtures o zakresie worker,scope: 'worker' - Parallelism —
workers,fullyParallel, jak Playwright rozdziela testy - testInfo.parallelIndex — zero-based indeks workera używany do przypisywania plików sesji
- locator.pressSequentially() — wpisywanie znak po znaku, istotne przy race condition Vue
v-model - Trace viewer — trace przeglądarki przechwytywany przy retry (
trace: 'on-first-retry')



