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

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 /login przez page.requestpage.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:

// 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=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:

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:

  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:

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:

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.

// 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 --tmpfs eliminuje I/O jako wąskie gardło i przyspiesza setup.
  • Sesje bazodanoweSESSION_DRIVER jest przestawiany na database przed startem serwera, jako druga warstwa izolacji sesji obok plików auth per-worker.
  • Rate limiting wyłączonyglobal-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 serweraphp 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?”

// 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

MetrykaPrzedPo
Workery14
Strategia auth1 wspólny auth.jsonPer-worker auth-N.json
Sterownik sesji (CI)filedatabase
Workery serwera PHP116
Czas działania>34 min (wiele pominiętych/niestabilnych)~5 min (~140 testów)
Tworzenie danych testowychNawigacja po UISeed API + bezpośredni URL
Konflikty sesjiTakNie

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 CISESSION_DRIVER=database eliminuje contention na flock() jako dodatkowe zabezpieczenie ponad izolację sesji
  • Workery serwera PHP CLIPHP_CLI_SERVER_WORKERS=16 zapobiega kolejkowaniu żądań za jednoprocesowym serwerem
  • Rate limiting wyłączony w CIRATE_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 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:

Powrót do bloga

Powiązane wpisy

Czytaj więcej
Aplikacja webowa vs strona internetowa

Aplikacja webowa vs strona internetowa

Strona internetowa prezentuje treść, aplikacja webowa pozwala użytkownikowi coś zrobić. Sprawdź różnice, kiedy wybrać które rozwiązanie i ile to kosztuje.