Przejdź do treści

Zaawansowane testy E2E z Playwright w Next.js

Opanuj automatyzację w Next.js App Router. Zobacz, jak testować procesy logowania, mockować zapytania API i wdrożyć testy wizualne w pipeline CI.

Maciej Sala

Founder StriveLab

5 min czytaniaOpublikowano 10 kwietnia 2026 (Aktualizacja 17 lipca 2026)

Dlaczego Playwright do testów E2E w Next.js App Router?

Dla Next.js App Router z Server Components Playwright jest rozsądnym domyślnym wyborem. Testuje renderowany HTML po streamingu, nawigację, formularze i interakcje. sam czeka na gotowość elementów, więc nie musisz rozsiewać po testach sztucznych opóźnień.

Setup Playwright w Next.js App Router

Code
npm init playwright@latest

Konfiguracja Playwright dla projektu Next.js

Code
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
 
const authFile = 'playwright/.auth/user.json'
const authenticatedTests = /.*\.authenticated\.spec\.ts/
const hasAuthCredentials = Boolean(
  process.env.E2E_USER_EMAIL && process.env.E2E_USER_PASSWORD,
)
 
export default defineConfig({
  testDir: './e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  // Playwright rekomenduje 1 worker na typowym CI; większy zestaw sharduj.
  workers: process.env.CI ? 1 : undefined,
 
  reporter: [['html'], process.env.CI ? ['github'] : ['list']],
 
  use: {
    baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://127.0.0.1:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },
 
  projects: [
    {
      name: 'chromium',
      testIgnore: authenticatedTests,
      use: { ...devices['Desktop Chrome'] },
    },
    {
      name: 'firefox',
      testIgnore: authenticatedTests,
      use: { ...devices['Desktop Firefox'] },
    },
    {
      name: 'mobile',
      testIgnore: authenticatedTests,
      use: { ...devices['iPhone 14'] },
    },
    ...(hasAuthCredentials
      ? [
          { name: 'setup', testMatch: /.*\.setup\.ts/ },
          {
            name: 'authenticated-chromium',
            testMatch: authenticatedTests,
            use: {
              ...devices['Desktop Chrome'],
              storageState: authFile,
            },
            dependencies: ['setup'],
          },
        ]
      : []),
  ],
 
  webServer: {
    command: 'npm run build && npm run start',
    url: 'http://127.0.0.1:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120000,
  },
})

Projekt setup i testy uwierzytelnione są dodawane tylko wtedy, gdy ustawisz oba sekrety E2E_USER_EMAIL i E2E_USER_PASSWORD. Dzięki temu zwykłe npx playwright test nie wymaga konta E2E, a CI może osobno uruchamiać zestaw uwierzytelniony ze zmiennymi z magazynu sekretów.

fullyParallel pozwala równoleglić także testy z jednego pliku, ale workers: 1 wyłącza realną współbieżność na CI. To celowy kompromis na małym współdzielonym runnerze, nie optymalizacja szybkości. Jeśli testy są izolowane i runner ma zasoby, zwiększ liczbę workerów; przy większym zestawie użyj shardingu między jobami. Retry nie jest naprawą niestabilnego testu. Raportuj testy oznaczone jako flaky i usuwaj ich przyczynę.

Build produkcyjny lepiej odwzorowuje zachowanie App Routera niż next dev, ale wydłuża lokalną pętlę. reuseExistingServer pozwala użyć uruchomionego wcześniej serwera. Ustaw dla testów osobną bazę i zmienne środowiskowe, aby E2E nigdy nie zapisywały danych produkcyjnych.

Podstawowe testy E2E w Playwright

Test nawigacji i treści strony

Code
// e2e/navigation.spec.ts
import { test, expect } from '@playwright/test'
 
test.describe('Nawigacja', () => {
  test('strona główna ładuje się poprawnie', async ({ page }) => {
    await page.goto('/')
 
    await expect(page).toHaveTitle(/Example App/)
    await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
  })
 
  test('nawigacja do podstron działa', async ({ page }) => {
    await page.goto('/')
 
    await page.getByRole('link', { name: /usługi/i }).click()
 
    await expect(page).toHaveURL('/uslugi')
    await expect(page.getByRole('heading', { name: /usługi/i })).toBeVisible()
  })
 
  test('widok not-found wyświetla się dla nieistniejących ścieżek', async ({
    page,
  }) => {
    await page.goto('/nieistniejaca-strona')
 
    await expect(
      page.getByRole('heading', { name: /nie znaleziono|404/i }),
    ).toBeVisible()
  })
})

W architekturze App Router nie należy bezwarunkowo polegać na statusie HTTP 404: jeśli odpowiedź rozpoczęła już streaming, wywołanie notFound() może zwrócić poprawny wizualnie widok błędu ze statusem 200. Z tego powodu test E2E powinien weryfikować docelowy widok interfejsu, natomiast status HTTP warto sprawdzać osobno, wyłącznie dla ścieżek, które gwarantują brak wcześniejszego streamingu.

Test formularza kontaktowego w Playwright

Code
// e2e/contact-form.spec.ts
import { test, expect } from '@playwright/test'
 
test.describe('Formularz kontaktowy', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('/kontakt')
  })
 
  test('wysyła formularz z poprawnymi danymi', async ({ page }) => {
    // Interceptuj request do API
    await page.route('**/api/contact', async (route) => {
      await route.fulfill({
        status: 200,
        contentType: 'application/json',
        body: JSON.stringify({ success: true }),
      })
    })
 
    await page.getByLabel(/imię/i).fill('Jan Kowalski')
    await page.getByLabel(/email/i).fill('jan@test.pl')
    await page.getByLabel(/wiadomość/i).fill('Chcę zlecić stronę w Next.js')
 
    await page.getByRole('button', { name: /wyślij/i }).click()
 
    await expect(page.getByText(/wysłana|dziękujemy/i)).toBeVisible()
  })
 
  test('wyświetla błędy walidacji', async ({ page }) => {
    // Kliknij bez wypełnienia
    await page.getByRole('button', { name: /wyślij/i }).click()
 
    await expect(page.getByText(/wymagane/i).first()).toBeVisible()
  })
 
  test('wyświetla błąd przy niepoprawnym email', async ({ page }) => {
    await page.getByLabel(/imię/i).fill('Jan')
    await page.getByLabel(/email/i).fill('nie-email')
    await page.getByLabel(/wiadomość/i).fill('Test')
 
    await page.getByRole('button', { name: /wyślij/i }).click()
 
    await expect(
      page.getByRole('alert').filter({ hasText: /poprawny adres e-mail/i }),
    ).toBeVisible()
  })
})

Testowanie auth flow w Next.js przez Playwright

Projekt setup i storageState dla zalogowanego użytkownika

Code
// e2e/auth.setup.ts
import { test as setup, expect } from '@playwright/test'
 
const authFile = 'playwright/.auth/user.json'
 
setup('authenticate', async ({ page }) => {
  const email = process.env.E2E_USER_EMAIL
  const password = process.env.E2E_USER_PASSWORD
 
  if (!email || !password) {
    throw new Error('Brak danych konta E2E')
  }
 
  await page.goto('/login')
  await page.getByLabel(/email/i).fill(email)
  await page.getByLabel(/hasło/i).fill(password)
  await page.getByRole('button', { name: /zaloguj/i }).click()
 
  await expect(page).toHaveURL('/dashboard')
  await expect(page.getByRole('button', { name: /konto/i })).toBeVisible()
 
  // Zapisuj dopiero po zakończeniu przekierowań i ustawieniu cookies.
  await page.context().storageState({ path: authFile })
})
Code
// e2e/dashboard.authenticated.spec.ts
import { test, expect } from '@playwright/test'
 
test('wyświetla dashboard po zalogowaniu', async ({ page }) => {
  await page.goto('/dashboard')
 
  await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible()
  await expect(page).not.toHaveURL('/login')
})

Pamiętaj, aby dodać katalog playwright/.auth do pliku .gitignore. Ponieważ plik storageState przechowuje ciasteczka oraz nagłówki umożliwiające przejęcie konta testowego, nie wolno go commitować nawet do prywatnego repozytorium. Taki wspólny stan sprawdza się wyłącznie w przypadku testów tylko do odczytu; jeśli scenariusze modyfikują profil, zawartość koszyka lub uprawnienia, należy przydzielić osobne konto dla każdego workera albo wygenerować dane za pomocą kontrolowanego API testowego.

Test pełnego login flow w aplikacji Next.js

Code
// e2e/auth-flow.spec.ts
import { test, expect } from '@playwright/test'
 
test('pełny flow logowania credentials', async ({ page }) => {
  const email = process.env.E2E_USER_EMAIL
  const password = process.env.E2E_USER_PASSWORD
  if (!email || !password) throw new Error('Brak danych konta E2E')
 
  await page.goto('/login')
 
  await page.getByLabel(/email/i).fill(email)
  await page.getByLabel(/hasło/i).fill(password)
  await page.getByRole('button', { name: /zaloguj/i }).click()
 
  // Po udanym logowaniu następuje redirect na dashboard.
  await expect(page).toHaveURL('/dashboard')
  await expect(page.getByText(/witaj/i)).toBeVisible()
})
 
test('nieprawidłowe hasło wyświetla błąd', async ({ page }) => {
  const email = process.env.E2E_USER_EMAIL
  if (!email) throw new Error('Brak adresu konta E2E')
 
  await page.goto('/login')
 
  await page.getByLabel(/email/i).fill(email)
  await page.getByLabel(/hasło/i).fill('wrong-password')
  await page.getByRole('button', { name: /zaloguj/i }).click()
 
  await expect(page.getByText(/nieprawidłowy/i)).toBeVisible()
  await expect(page).toHaveURL('/login')
})

API intercepting w Playwright: mockowanie backendu

pozwala kontrolować żądania wykonywane przez stronę w przeglądarce. Przechwytujesz je i sam decydujesz, co zwrócić.

Code
// e2e/products.spec.ts
import { test, expect } from '@playwright/test'
 
test('Client Component wyświetla produkty z API', async ({ page }) => {
  await page.route('https://api.example.com/products*', async (route) => {
    await route.fulfill({
      status: 200,
      contentType: 'application/json',
      body: JSON.stringify([
        { id: '1', name: 'Widget Pro', price: 299 },
        { id: '2', name: 'Gadget Ultra', price: 499 },
      ]),
    })
  })
 
  await page.goto('/products')
 
  await expect(page.getByText('Widget Pro')).toBeVisible()
  await expect(page.getByText('Gadget Ultra')).toBeVisible()
})
 
test('Client Component obsługuje błąd API', async ({ page }) => {
  await page.route('https://api.example.com/products*', async (route) => {
    await route.fulfill({ status: 500, body: 'Internal Server Error' })
  })
 
  await page.goto('/products')
 
  await expect(page.getByText(/błąd|spróbuj ponownie/i)).toBeVisible()
})

page.route i browserContext.route działają na ruchu widzianym przez przeglądarkę. Nie przechwycą fetch wykonanego podczas renderowania Server Component przez proces Node.js uruchomiony w webServer. Taki scenariusz testuj z kontrolowanym backendem lub bazą E2E, seedem danych albo zależnością podmienianą konfiguracją serwera. Nie mockuj prywatnego protokołu Server Actions. Sprawdzaj zachowanie formularza przeciwko testowemu środowisku aplikacji.

Jeśli routing nie widzi requestów obsługiwanych przez Service Workera, ustaw dla danego projektu serviceWorkers: 'block' albo użyj browserContext.route zgodnie z architekturą aplikacji. Mock powinien być zarejestrowany przed akcją, która inicjuje żądanie; przy fetchu wykonywanym podczas ładowania strony oznacza to przed page.goto().

Visual regression testing w Playwright

porównuje render strony z zapisanym wcześniej wzorcem i wyłapuje wizualne regresje, które przejdą niezauważone przez testy sprawdzające tylko treść:

Code
// e2e/visual.spec.ts
import { test, expect } from '@playwright/test'
 
test('strona główna jako visual snapshot', async ({ page }) => {
  await page.goto('/')
  await expect(page.getByRole('heading', { level: 1 })).toBeVisible()
  await page.evaluate(async () => {
    await document.fonts.ready
  })
 
  await expect(page).toHaveScreenshot('homepage.png', {
    fullPage: true,
    animations: 'disabled',
    maxDiffPixelRatio: 0.001,
  })
})
 
test('strona kontakt w widoku mobile', async ({ page }) => {
  await page.setViewportSize({ width: 375, height: 812 })
  await page.goto('/kontakt')
  await expect(page.getByRole('main')).toBeVisible()
  await page.evaluate(async () => {
    await document.fonts.ready
  })
 
  await expect(page).toHaveScreenshot('contact-mobile.png', {
    animations: 'disabled',
    maxDiffPixelRatio: 0.001,
  })
})

Przy pierwszym uruchomieniu Playwright generuje obrazy referencyjne, a przy kolejnych porównuje aktualny wynik z bazą i zgłasza błąd, jeśli różnica przekracza ustalony próg. Unikaj stosowania networkidle jako sygnału gotowości, ponieważ Playwright odradza tę strategię w testach automatycznych. Zamiast tego czekaj na konkretny stan interfejsu za pomocą asercji typu web-first, a zmienne elementy, takie jak czas, losowe treści czy reklamy, stabilizuj lub maskuj na czas testu.

Aktualizacja screenshotów po celowej zmianie UI:

Code
npx playwright test --update-snapshots

Wyniki testów wizualnych zależą od systemu operacyjnego, silnika przeglądarki, fontów oraz środowiska renderowania. Aby uniknąć fałszywych alarmów, generuj i porównuj obrazy referencyjne w tym samym kontenerze lub środowisku CI, rezygnując z ich automatycznej aktualizacji po nieudanym teście. Nowe snapshoty powinny zawsze trafiać do code review, ponieważ zbyt wysoki próg tolerancji może równie skutecznie ukryć realną regresję graficzną, co wyeliminować zakłócenia.

Testy responsywności i multi-device w Playwright

Code
test.describe('Responsywność', () => {
  const viewports = [
    { name: 'mobile', width: 375, height: 812 },
    { name: 'tablet', width: 768, height: 1024 },
    { name: 'desktop', width: 1440, height: 900 },
  ]
 
  for (const viewport of viewports) {
    test(`nawigacja na ${viewport.name}`, async ({ page }) => {
      await page.setViewportSize({
        width: viewport.width,
        height: viewport.height,
      })
      await page.goto('/')
 
      if (viewport.width < 768) {
        // Mobile z menu hamburger.
        const menuButton = page.getByRole('button', { name: /menu/i })
        await expect(menuButton).toBeVisible()
        await menuButton.click()
        await expect(page.getByRole('navigation')).toBeVisible()
      } else {
        // Desktop z widocznym menu.
        await expect(page.getByRole('navigation')).toBeVisible()
      }
    })
  }
})

Izolacja danych i równoległe testy

Playwright tworzy nowy BrowserContext dla każdego testu, więc cookies i local storage są izolowane. Nie izoluje jednak bazy danych, kolejki ani skrzynki e-mail. Każdy test powinien tworzyć własne rekordy z unikalnym identyfikatorem i usuwać je po zakończeniu albo pracować na bazie resetowanej między przebiegami. Nie buduj scenariusza, w którym jeden test zakłada dane utworzone przez poprzedni.

Wspólne konto jest bezpieczne dla równoległych testów tylko wtedy, gdy nie modyfikują tego samego stanu. Dla operacji zapisu przydziel konto per worker lub seeduj użytkownika przez chronione API dostępne wyłącznie w środowisku E2E. test.describe.serial zostaw dla rzeczywiście nierozdzielnych scenariuszy. Serializacja zwykle maskuje brak izolacji i pogarsza czas wykonania oraz retry.

Playwright w GitHub Actions

Code
name: Playwright E2E
 
on:
  pull_request:
 
jobs:
  e2e:
    runs-on: ubuntu-latest
    timeout-minutes: 30
 
    steps:
      - uses: actions/checkout@v5
      - uses: actions/setup-node@v6
        with:
          node-version: lts/*
          cache: npm
 
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test --project=chromium
 
      - uses: actions/upload-artifact@v5
        if: ${{ !cancelled() }}
        with:
          name: playwright-report
          path: |
            playwright-report/
            test-results/
          retention-days: 14

Szybki projekt Chromium może blokować pull request, a pełną macierz Chromium/Firefox/WebKit i visual regression możesz uruchamiać nocą lub przed wydaniem. Sekrety kont E2E trzymaj w magazynie sekretów CI, ogranicz ich uprawnienia i kieruj testy wyłącznie do izolowanego środowiska. Przy dużym zestawie dziel testy przez --shard=N/M; raporty z shardów można połączyć przez blob reporter.

Testy automatyczne komponentów i E2E w Cypress.
QA & Automation

Często zadawane pytania

Ile testów E2E pisać?

Tyle, żeby pokryć krytyczne ścieżki i najważniejsze granice integracji. Nie ma uniwersalnej liczby. Logowanie, główny proces biznesowy, formularz kontaktowy i podstawowa nawigacja często tworzą dobry zestaw startowy. Liczbę testów dobieraj do ryzyka i częstotliwości zmian, a przypadki brzegowe pokrywaj tańszymi testami jednostkowymi i integracyjnymi.

Jak przyspieszyć testy E2E?

Najpierw usuń zależności między testami i przygotuj dane per worker. reuseExistingServer może wykorzystać lokalnie działającą aplikację, a Chromium wystarcza do szybkiego smoke suite na pull requestach. Playwright zaleca jeden worker na typowym CI dla stabilności; większe zestawy lepiej dzielić na shardy lub uruchamiać równolegle na odpowiednio mocnym runnerze. page.route przyspiesza tylko żądania wykonywane przez przeglądarkę. Nie mockuje zapytań Server Components wykonywanych przez proces Next.js.

Playwright czy Cypress w 2026?

Wybór odpowiedniego narzędzia do testów E2E zależy od specyficznych potrzeb zespołu, ponieważ zarówno Playwright, jak i Cypress sprawdzają się w tej roli. Playwright oferuje wbudowane wsparcie dla Chromium, Firefox i WebKit, izolowane konteksty przeglądarki, zaawansowany tracing oraz wygodną obsługę wielu kart i ról użytkowników. Z kolei Cypress może okazać się lepszym wyborem dla zespołów, które dobrze znają jego ekosystem i preferowany sposób debugowania. Niezależnie od wybranej technologii, samo narzędzie nie gwarantuje szybkości ani stabilności, ponieważ o sukcesie decyduje właściwa izolacja danych, przemyślane selektory oraz solidna architektura testów.

Jak testować zalogowane widoki bez przeklikiwania loginu?

Najczęściej przez projekt setup, który loguje konto testowe i zapisuje storageState, a projekty zależne uruchamiają izolowane konteksty z tym stanem. Plik może zawierać cookies i nagłówki pozwalające przejąć sesję, więc umieść playwright/.auth w .gitignore. W sytuacji, gdy testy równolegle modyfikują dane konta, użyj osobnego konta lub stanu per worker.

Jak działa visual regression w Playwright?

Przy pierwszym uruchomieniu toHaveScreenshot zapisuje zrzut ekranu jako referencję. Przy kolejnych przebiegach Playwright robi nowy zrzut i porównuje go z referencyjnym piksel po pikselu; jeśli różnica przekracza ustalony próg (np. maxDiffPixelRatio), test failuje. To skutecznie wyłapuje niezamierzone zmiany w CSS i układzie. Po celowej zmianie wyglądu aktualizujesz referencje komendą npx playwright test --update-snapshots, a zmiany obrazów przeglądasz tak samo jak kod.

O autorze

Maciej Sala

Maciej Sala — Product Manager i Frontend Developer z bogatym doświadczeniem w marketingu internetowym oraz SEO. Na co dzień pracuje z Reactem, Next.js i TypeScriptem, a ostatnio także z Astro i narzędziami do automatyzacji procesów AI. Sprawnie łączy perspektywę produktową z praktycznym podejściem do kodu. Przez kilka lat był związany z branżą gier wideo jako project manager i game designer. Absolwent historii na Uniwersytecie Jagiellońskim oraz studiów podyplomowych z marketingu internetowego na AGH w Krakowie. Po godzinach trenuje na siłowni, maluje figurki i rozwijam własne projekty.

Pomagam przekładać takie tematy na konkretne wdrożenia w frontendzie, SEO, analityce i procesie produktowym.

Skontaktuj się ze mną

Biblioteka wiedzy na temat Next.js

Czytaj dalej

Zobacz więcej wpisów
Cypress vs Playwright – który wybrać do projektu Next.js?

Dogłębne porównanie Cypress i Playwright w kontekście projektów Next.js. doświadczenie deweloperskie, szybkość, wsparcie dla SSR i React – który framework testowy wybrać?

Maciej Sala

Maciej Sala

Founder StriveLab

Automatyczne testy regresji SEO w GitHub Actions — noindex, link kanoniczny i redirecty pod kontrolą CI/CD

Jedna linijka w niewłaściwym miejscu — , która miała zostać tylko na środowisku testowym — i strona znika z Google na tygodnie. To jeden z najdroższych błędów w SEO technicznym, ponieważ długo pozostaje niewidoczny: build przechodzi, strona działa, użytkownicy niczego nie zauważają, a ruch organiczny po cichu się osuwa. Dobra wiadomość jest taka, że tę klasę błędów da się złapać automatycznie, zanim kod w ogóle trafi na produkcję. W tym artykule pokazuję, jak zbudować testy regresji SEO w Playwright i wpiąć je w GitHub Actions , żeby pull request z zepsutym linkiem kanonicznym czy przypadkowym noindexem po prostu nie przeszedł.

Maciej Sala

Maciej Sala

Founder StriveLab

E2E testy w Next.js App Router – kompletny setup Cypress + CI/CD

Od instalacji, przez fixtures i custom commands, po integrację z GitHub Actions. Kompletny przewodnik po konfiguracji testów E2E Cypress w projekcie Next.js z App Router.

Maciej Sala

Maciej Sala

Founder StriveLab