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

**Canonical:** https://spoko.space/pl/blog/playwright-laravel-rownolegle-testy-e2e/  
**Language:** pl  
**Published:** 2026-04-24  
**Tags:** Playwright, Laravel, testowanie, TypeScript, CI/CD  
**Category:** Engineering

---
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](https://www.rocksoft.pl/blog/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ę:

```typescript
// selectors/service.selectors.ts

```

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:

```typescript
// pages/service.page.ts

  }

  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.

```typescript
// helpers/service-form.helper.ts

  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`:

```typescript
// 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 `/login` przez `page.request` — `page.request` ma 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:

```typescript
// 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`:

```typescript
// fixtures/auth.fixture.ts

    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:

```typescript

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:

```bash
RATE_LIMIT_ENABLED=false
```

Bezpieczne 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:

```typescript
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:

```bash
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:

1. **Wolność.** Wieloetapowy przepływ UI do tworzenia klienta może zająć 5–10 sekund zanim właściwy test się w ogóle zacznie.
2. **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:

```typescript
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 konfiguracyjnej
```

Waż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:

```typescript
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 exceeded
```

Naprawa: 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.

```php
// 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:

```typescript

```

**`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 `--tmpfs` eliminuje I/O jako wąskie gardło i przyspiesza setup.
- **Sesje bazodanowe** — `SESSION_DRIVER` jest przestawiany na `database` przed startem serwera, jako druga warstwa izolacji sesji obok plików auth per-worker.
- **Rate limiting wyłączony** — `global-setup.ts` loguje 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 serve` zwraca kontrolę natychmiast, gdy serwer jeszcze się uruchamia. Krótka pętla curl przed `npx playwright test` upewnia się, że strona logowania jest dostępna gdy `global-setup.ts` startuje.

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?"

```typescript
// 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 przez `testInfo.parallelIndex`
- **Sesje bazodanowe w CI** — `SESSION_DRIVER=database` eliminuje contention na `flock()` jako dodatkowe zabezpieczenie ponad izolację sesji
- **Workery serwera PHP CLI** — `PHP_CLI_SERVER_WORKERS=16` zapobiega kolejkowaniu żądań za jednoprocesowym serwerem
- **Rate limiting wyłączony w CI** — `RATE_LIMIT_ENABLED=false` zapobiega 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 zamiast `page.request`, żeby wysłać rzeczywiste ciasteczko sesji
- **`RefreshDatabaseState::$migrated = true`** — zmigruj bazę przed równoległym przebiegiem PHPUnit; workery przechodzą prosto do transakcji
- **`fullyParallel: 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 serve` zwraca 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?

Tak, z zastrzeżeniami. JWT jest bezstanowy — nie ma pliku sesji po stronie serwera, więc współdzielenie tokena między workerami jest bezpieczne i naprawa per-worker nie jest potrzebna. Sanctum w trybie cookie (domyślny dla SPA) używa sesji Laravel pod spodem, więc ten sam problem z flock() dotyczy. Sanctum w trybie tokenowym (Authorization: Bearer) jest bezstanowy i nie jest dotknięty.

### Dlaczego page.evaluate(fetch) zamiast page.request dla seed API?

page.request Playwright ma własny izolowany jar ciasteczek — nie współdzieli ciasteczek z kontekstem przeglądarki. To znaczy, że nie może uwierzytelnić się jako zalogowany użytkownik. page.evaluate(fetch) działa wewnątrz przeglądarki, używa tego samego ciasteczka sesji co przeglądarka i trafia do endpointu seed jako uwierzytelniony użytkownik. Ta różnica zaskakuje większość programistów przy pierwszej próbie wykonania uwierzytelnionych wywołań API z testu.

### Czy seed API może przypadkowo zostać włączone na produkcji?

Routy są rejestrowane warunkowo — tylko gdy APP_ENV nie jest 'production'. Jako druga warstwa, są w osobnym pliku routów (routes/web/e2e.php) ładowanym tylko w środowiskach innych niż produkcja. W praktyce, deploy produkcyjny, który jakoś załadowałby routy e2e, nadal wymagałby poprawnego ciasteczka sesji, ponieważ używają standardowego middleware uwierzytelniania Laravel.

### Dlaczego fullyParallel: false zamiast true?

fullyParallel: true uruchamia każdy test współbieżnie we wszystkich workerach. fullyParallel: false zrównolegla na poziomie pliku — testy w jednym pliku spec działają sekwencyjnie. W aplikacji CRUD powiązane testy często mają niejawny kontekst: jeden test tworzy rekord, następny go weryfikuje. Kolejność wewnątrz pliku jest zwykle celowa. Zrównoleglenie na poziomie pliku daje większość korzyści prędkościowych bez łamania tej niejawnej umowy.

### Czy pressSequentially spowalnia testy zauważalnie?

Tylko w global-setup, gdzie jest używany do logowania. Przy opóźnieniu 10ms na znak i typowym emailu + haśle ok. 30 znaków łącznie, dodaje ok. 300ms na login. Przy 4 workerach to ok. 1,2 sekundy dodatkowego setup time — bez znaczenia wobec 5-minutowego suite. Wewnątrz właściwych plików spec fill() działa poprawnie dla pól formularzy bez problemu z race condition Inertia useForm.

### Czy to dotyczy mnie jeśli używam Herd lub Valet lokalnie?

Nie — naprawa PHP_CLI_SERVER_WORKERS jest potrzebna tylko dla php artisan serve, który jest jednoprocesowy. Herd i Valet stoją przed PHP-FPM przez nginx, który obsługuje równoległe żądania poprawnie. Izolacja sesji per-worker nadal ma znaczenie lokalnie przy wielu workerach, ale bottleneck serwera PHP po prostu nie istnieje.

---

## 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](https://claude.ai/code) z [serwerem Playwright MCP](https://playwright.dev/docs/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](https://playwright.dev/docs/auth) — standardowy wzorzec `storageState` i kiedy działa
- [Global setup and teardown](https://playwright.dev/docs/test-global-setup-teardown) — `global-setup.ts`, uruchamianie kodu przed wszystkimi testami
- [Fixtures](https://playwright.dev/docs/test-fixtures) — rozszerzanie przez `base.extend()`, fixtures o zakresie worker, `scope: 'worker'`
- [Parallelism](https://playwright.dev/docs/test-parallel) — `workers`, `fullyParallel`, jak Playwright rozdziela testy
- [testInfo.parallelIndex](https://playwright.dev/docs/api/class-testinfo#test-info-parallel-index) — zero-based indeks workera używany do przypisywania plików sesji
- [locator.pressSequentially()](https://playwright.dev/docs/api/class-locator#locator-press-sequentially) — wpisywanie znak po znaku, istotne przy race condition Vue `v-model`
- [Trace viewer](https://playwright.dev/docs/trace-viewer) — trace przeglądarki przechwytywany przy retry (`trace: 'on-first-retry'`)
