Playwright, testy end to end i automatyzacja przeglądarki
Playwright to narzędzie do automatyzacji przeglądarek, używane głównie do testów end to end. Rozwija je Microsoft, licencja to Apache 2.0, a bieżąca wersja to 1.62.1 z 30 lipca 2026 roku. Od konkurencji odróżnia je obsługa trzech silników przeglądarek z jednego kodu testu oraz mechanizm, który sam czeka na gotowość elementu.
Dlaczego akurat to narzędzie wygrało
Przez lata testy w przeglądarce miały opinię czegoś, co się pisze niechętnie i utrzymuje jeszcze mniej chętnie. Powód był konkretny: testy zawodziły losowo. Element pojawiał się o ułamek sekundy za późno, animacja jeszcze trwała, żądanie sieciowe nie wróciło. Odpowiedzią było dopisywanie sztucznych opóźnień, co pogarszało sprawę, bo testy stawały się wolne i nadal migotliwe.
Ten problem został tu rozwiązany u źródła. Zanim narzędzie kliknie w element, samo sprawdza, czy element istnieje, jest widoczny, nie jest zasłonięty i przyjmuje zdarzenia. Czeka, aż wszystkie te warunki będą spełnione, i dopiero wtedy działa. Skutek jest taki, że sztuczne opóźnienia przestają być potrzebne, a testy przestają zawodzić bez powodu.
Druga rzecz to trzy silniki przeglądarek z jednego kodu. Nie chodzi tu o zgodność wsteczną ze starymi przeglądarkami, tylko o wyłapanie różnic, które faktycznie występują: inne zachowanie pól formularza, inne traktowanie zdarzeń dotyku, inne domyślne style. Przy aplikacji na Next.js najczęściej wychodzą tu rzeczy związane z hydratacją i z komponentami, które inaczej zachowują się przy pierwszym renderowaniu.
Warto przy tym wiedzieć, że uruchamianie każdego testu we wszystkich trzech silnikach potraja czas wykonania, a wartość tego jest nierówna. Rozsądny układ to pełny zestaw w jednym silniku przy każdej zmianie i przebieg we wszystkich trzech raz dziennie albo przed wydaniem. Różnice między silnikami są realne, ale rzadko pojawiają się z dnia na dzień, więc codzienne płacenie za nie pełnym czasem wykonania jest marnotrawstwem.
Trzecia to narzędzia do diagnozowania. Zapis śladu wykonania rejestruje zrzuty ekranu przed i po każdej operacji, stan sieci, wpisy w dzienniku i strukturę strony. Test, który padł na serwerze budującym, przestaje być zagadką, bo otwierasz plik śladu i widzisz dokładnie, co się działo.
Czwarta rzecz, rzadziej wymieniana, to nagrywanie testów przez interakcję. Uruchamiasz narzędzie, klikasz po aplikacji jak użytkownik, a ono zapisuje odpowiadający temu kod. To nie jest sposób na pisanie testów produkcyjnych, bo wygenerowany kod bywa dosłowny i kruchy, ale świetnie działa jako punkt wyjścia i jako sposób na sprawdzenie, jakiego lokatora użyć do trudnego elementu.
Testy wizualne i kiedy się sprawdzają
Porównywanie zrzutów ekranu z wzorcem to funkcja, po którą sięga się chętnie i szybko zniechęca, więc warto wiedzieć, gdzie działa dobrze, a gdzie przynosi więcej szkody niż pożytku.
Mechanizm jest prosty: przy pierwszym uruchomieniu powstaje wzorzec, przy kolejnych narzędzie porównuje z nim aktualny zrzut i zgłasza różnicę powyżej progu tolerancji. Brzmi idealnie do wyłapywania niezamierzonych zmian w wyglądzie.
Problem polega na tym, że renderowanie różni się między systemami. Ten sam kod na macOS i na Linuksie da inne wygładzanie krawędzi czcionek, a różnica wystarczy, żeby porównanie zawiodło. Dlatego wzorce trzeba generować w tym samym środowisku, w którym testy się wykonują, czyli zwykle w kontenerze używanym na serwerze budującym, a nie na laptopie autora.
Druga pułapka to zakres. Zrzut całej strony psuje się przy każdej zmianie treści, w tym przy zmianie tekstu, którego nikt nie testował. Porównania warto ograniczać do konkretnego komponentu i do tych elementów interfejsu, których wygląd faktycznie jest umową z użytkownikiem.
Trzecia to elementy zmienne. Data, licznik, awatar losowany z zestawu i animacja sprawią, że test będzie zawodził zawsze. Da się je zamaskować przed porównaniem i to jest właściwa kolejność działania, a nie podnoszenie progu tolerancji, bo podniesiony próg przestaje wykrywać cokolwiek.
Jeśli po pierwszym miesiącu zespół zaczyna odruchowo aktualizować wzorce bez patrzenia na różnice, to znaczy, że testów wizualnych jest za dużo albo są zbyt szerokie. Kilka celnych porównań ma wartość, kilkadziesiąt przypadkowych staje się rytuałem.
Lokatory, czyli jak nie pisać kruchych testów
To jest miejsce, w którym najczęściej podejmuje się złe decyzje, a konsekwencje płaci przez następny rok.
Kuszące jest wskazywanie elementów przez klasy CSS albo strukturę drzewa dokumentu. Działa natychmiast i psuje się przy pierwszej zmianie stylów, bo test przestaje znajdować przycisk, mimo że przycisk nadal tam jest i nadal działa.
Podejście, które się broni, polega na wskazywaniu elementów tak, jak robi to użytkownik: po roli i widocznej nazwie. Przycisk z napisem „Zapisz" znajduje się jako przycisk o tej nazwie, a nie jako trzeci element o klasie zaczynającej się od losowego skrótu.
await page.getByRole('button', { name: 'Zapisz' }).click()
await page.getByLabel('Adres e-mail').fill('anna@example.com')Ma to skutek uboczny, o którym warto wiedzieć, bo bywa najlepszym argumentem za tym podejściem w rozmowie z zespołem. Jeśli nie da się wskazać elementu po roli i nazwie, to znaczy, że element nie ma dostępnej nazwy, a więc czytnik ekranu też go nie opisze. Test wymusza wtedy poprawkę, która przy okazji naprawia dostępność.
Kiedy naprawdę nie da się inaczej, zostaje atrybut testowy. To rozwiązanie ostateczne, nie domyślne, bo tworzy element istniejący wyłącznie na potrzeby testu, który ktoś kiedyś usunie jako niepotrzebny. Jeśli sięgasz po niego regularnie, warto zadać sobie pytanie, czy problem nie leży w samym interfejsie: przycisk bez tekstu, ikona bez opisu i pole bez etykiety to elementy trudne do wskazania w teście dokładnie z tego samego powodu, dla którego są trudne w obsłudze bez wzroku.
Osobną sprawą jest wybór między czekaniem na element a czekaniem na stan. Mechanizm automatyczny obejmuje pojedynczą operację, ale nie wie, że po kliknięciu zapisu ma jeszcze przeładować się lista. Sprawdzanie skutku, czyli oczekiwanie na pojawienie się nowego wiersza, działa tu lepiej niż jakiekolwiek odliczanie, bo test kończy się dokładnie wtedy, gdy warunek jest spełniony, a nie po ustalonym czasie.
Nowy model testów komponentów
Wersja 1.62 zmieniła sposób testowania komponentów i warto o tym wiedzieć, bo starsze poradniki opisują poprzednie podejście.
Nowy model opiera się na scenach i galeriach. Scena opakowuje komponent w jednej konkretnej sytuacji, z ustalonymi właściwościami, danymi zastępczymi i kontekstem, a galeria to strona, która te sceny renderuje na żądanie. Test montuje wybraną scenę i sprawdza, co się wyświetliło.
Zmiana ma sens praktyczny. Poprzednie podejście wymagało budowania komponentu w kontekście testu, co bywało kruche przy złożonych zależnościach. Teraz komponent renderuje się tam, gdzie normalnie żyje, czyli w Twojej aplikacji z Twoją konfiguracją, a test tylko po niego sięga.
Warto natomiast rozważyć, czy testy komponentów w przeglądarce są tym, czego potrzebujesz. Uruchomienie prawdziwej przeglądarki kosztuje czas, a większość logiki komponentu sprawdzisz szybciej narzędziem działającym bez niej, na przykład Vitestem. Przeglądarka jest potrzebna tam, gdzie liczy się faktyczne renderowanie: układ, style warunkowe, zachowanie przy różnych rozmiarach okna.
Czym jest Playwright?
Playwright to nowoczesny framework do automatyzacji przeglądarek i testów end-to-end stworzony przez Microsoft. Wspiera wszystkie główne przeglądarki (Chromium, Firefox, WebKit) z jednym spójnym API, oferując szybkie, niezawodne i cross-browser testowanie aplikacji webowych.
W przeciwieństwie do starszych narzędzi jak Selenium, Playwright został zaprojektowany od podstaw z myślą o nowoczesnych aplikacjach - obsługuje auto-waiting, web components, shadow DOM, iframes i wiele innych trudnych scenariuszy bez dodatkowej konfiguracji.
Dlaczego Playwright?
Kluczowe zalety
- Cross-browser - Chromium, Firefox, WebKit z jednym API
- Auto-waiting - Automatyczne czekanie na elementy
- Szybkość - Równoległe wykonywanie testów
- Niezawodność - Brak flaky tests dzięki smart retries
- Codegen - Nagrywanie testów przez interakcję
- Trace Viewer - Debugowanie z pełnym kontekstem
- API Testing - Testy REST API wbudowane
Playwright vs Cypress vs Selenium
| Cecha | Playwright | Cypress | Selenium |
|---|---|---|---|
| Przeglądarki | Chromium, Firefox, WebKit | Chromium i Edge, Firefox, WebKit eksperymentalnie | Wszystkie |
| Szybkość | Bardzo szybki | Szybki | Wolny |
| Auto-waiting | Wbudowane | Wbudowane | Ręczne |
| Parallel | Natywne | Przez Cypress Cloud, darmowy plan limitowany | Wymaga Grid |
| iframes | Pełne wsparcie | Ograniczone | Pełne |
| Mobile emulation | Tak | Nie | Przez Appium |
| API testing | Wbudowane | Plugin | Zewnętrzne |
| Język | JS/TS, Python, .NET, Java | Tylko JS/TS | Wszystkie |
Instalacja i konfiguracja
Nowy projekt
# Inicjalizacja projektu Playwright
npm init playwright@latest
# Odpowiedz na pytania:
# - TypeScript lub JavaScript
# - Folder na testy (tests/)
# - Dodać GitHub Actions workflow?
# - Zainstalować przeglądarki?Struktura projektu
my-project/
├── tests/
│ ├── example.spec.ts
│ └── auth.spec.ts
├── playwright.config.ts
├── package.json
└── .github/
└── workflows/
└── playwright.ymlKonfiguracja playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
// Folder z testami
testDir: './tests',
// Uruchom testy równolegle
fullyParallel: true,
// Fail build on CI if you accidentally left test.only
forbidOnly: !!process.env.CI,
// Retry failed tests on CI
retries: process.env.CI ? 2 : 0,
// Workers - równoległe procesy
workers: process.env.CI ? 1 : undefined,
// Reporter
reporter: [
['html'],
['list'],
process.env.CI ? ['github'] : ['line']
],
// Globalne ustawienia testów
use: {
// Base URL
baseURL: 'http://localhost:3000',
// Trace on failure
trace: 'on-first-retry',
// Screenshot on failure
screenshot: 'only-on-failure',
// Video on failure
video: 'on-first-retry',
},
// Projekty = konfiguracje przeglądarek
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
// Mobile
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] },
},
{
name: 'Mobile Safari',
use: { ...devices['iPhone 12'] },
},
],
// Uruchom dev server przed testami
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})Pisanie testów
Podstawowa struktura
import { test, expect } from '@playwright/test'
test.describe('Homepage', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/')
})
test('has correct title', async ({ page }) => {
await expect(page).toHaveTitle(/My App/)
})
test('navigation works', async ({ page }) => {
await page.getByRole('link', { name: 'About' }).click()
await expect(page).toHaveURL('/about')
})
})Locators - znajdowanie elementów
import { test, expect } from '@playwright/test'
test('locators examples', async ({ page }) => {
await page.goto('/form')
// Zalecane - lokatory semantyczne
// Role-based (accessibility)
await page.getByRole('button', { name: 'Submit' }).click()
await page.getByRole('link', { name: 'Home' }).click()
await page.getByRole('heading', { name: 'Welcome' })
await page.getByRole('textbox', { name: 'Email' }).fill('test@example.com')
await page.getByRole('checkbox', { name: 'Accept terms' }).check()
// Label-based
await page.getByLabel('Email').fill('test@example.com')
await page.getByLabel('Password').fill('secret123')
// Placeholder
await page.getByPlaceholder('Enter your email').fill('test@example.com')
// Text content
await page.getByText('Welcome back')
await page.getByText(/welcome/i) // Regex, case-insensitive
// Test ID (dla skomplikowanych przypadków)
await page.getByTestId('submit-button').click()
// Ostatecznosc - CSS/XPath, mniej stabilne
await page.locator('.submit-btn').click()
await page.locator('#email-input').fill('test@example.com')
await page.locator('button[type="submit"]').click()
await page.locator('//button[text()="Submit"]').click() // XPath
})Łańcuchowanie locatorów
test('chained locators', async ({ page }) => {
// Znajdź element w kontekście innego
const form = page.locator('form#login')
await form.getByLabel('Email').fill('test@example.com')
await form.getByLabel('Password').fill('secret')
await form.getByRole('button', { name: 'Login' }).click()
// Filtrowanie
const rows = page.getByRole('row')
const activeRow = rows.filter({ hasText: 'Active' })
await activeRow.getByRole('button', { name: 'Edit' }).click()
// Nth element
await page.getByRole('listitem').nth(2).click() // Trzeci element
await page.getByRole('listitem').first().click()
await page.getByRole('listitem').last().click()
})Asercje
import { test, expect } from '@playwright/test'
test('assertions examples', async ({ page }) => {
await page.goto('/dashboard')
// Page assertions
await expect(page).toHaveTitle('Dashboard')
await expect(page).toHaveURL(/dashboard/)
await expect(page).toHaveURL('http://localhost:3000/dashboard')
// Element visibility
await expect(page.getByText('Welcome')).toBeVisible()
await expect(page.getByText('Error')).toBeHidden()
await expect(page.getByTestId('loading')).not.toBeVisible()
// Element state
await expect(page.getByRole('button')).toBeEnabled()
await expect(page.getByRole('button')).toBeDisabled()
await expect(page.getByRole('checkbox')).toBeChecked()
await expect(page.getByRole('textbox')).toBeEditable()
await expect(page.getByRole('textbox')).toBeFocused()
// Element content
await expect(page.getByRole('heading')).toHaveText('Dashboard')
await expect(page.getByRole('heading')).toContainText('Dash')
await expect(page.getByRole('textbox')).toHaveValue('initial value')
await expect(page.getByRole('textbox')).toBeEmpty()
// Element attributes
await expect(page.getByRole('link')).toHaveAttribute('href', '/about')
await expect(page.getByRole('img')).toHaveAttribute('alt', /logo/i)
// CSS
await expect(page.getByTestId('box')).toHaveCSS('color', 'rgb(0, 0, 0)')
await expect(page.getByRole('button')).toHaveClass(/primary/)
// Count
await expect(page.getByRole('listitem')).toHaveCount(5)
// Soft assertions (nie przerywają testu)
await expect.soft(page.getByText('Optional')).toBeVisible()
})Interakcje
test('interactions', async ({ page }) => {
await page.goto('/form')
// Klikanie
await page.getByRole('button').click()
await page.getByRole('button').click({ button: 'right' }) // Right click
await page.getByRole('button').dblclick() // Double click
await page.getByRole('button').click({ modifiers: ['Control'] }) // Ctrl+click
// Hover
await page.getByText('Menu').hover()
// Wypełnianie formularzy
await page.getByLabel('Email').fill('test@example.com')
await page.getByLabel('Email').clear()
await page.getByLabel('Email').type('test@example.com') // Keystroke by keystroke
// Press keys
await page.getByLabel('Search').press('Enter')
await page.keyboard.press('Escape')
await page.keyboard.press('Control+A')
// Select dropdown
await page.getByLabel('Country').selectOption('poland')
await page.getByLabel('Country').selectOption({ label: 'Poland' })
await page.getByLabel('Multi').selectOption(['opt1', 'opt2'])
// Checkbox & radio
await page.getByLabel('Accept').check()
await page.getByLabel('Accept').uncheck()
await page.getByLabel('Accept').setChecked(true)
// File upload
await page.getByLabel('Upload').setInputFiles('path/to/file.pdf')
await page.getByLabel('Upload').setInputFiles(['file1.pdf', 'file2.pdf'])
// Drag and drop
await page.getByTestId('source').dragTo(page.getByTestId('target'))
// Focus
await page.getByLabel('Email').focus()
await page.getByLabel('Email').blur()
})Czekanie i timeouty
test('waiting', async ({ page }) => {
await page.goto('/dashboard')
// Auto-waiting - Playwright automatycznie czeka na elementy
// Poniższe samo w sobie czeka aż element będzie widoczny i klikalny
await page.getByRole('button').click()
// Explicit waits
await page.waitForSelector('.loaded')
await page.waitForLoadState('networkidle')
await page.waitForLoadState('domcontentloaded')
await page.waitForURL('/dashboard')
// Wait for response
const responsePromise = page.waitForResponse('**/api/data')
await page.getByRole('button', { name: 'Load' }).click()
const response = await responsePromise
// Wait for request
const requestPromise = page.waitForRequest('**/api/submit')
await page.getByRole('button', { name: 'Submit' }).click()
await requestPromise
// Custom wait
await page.waitForFunction(() => {
return document.querySelector('.counter')?.textContent === '10'
})
// Timeout override
await page.getByRole('button').click({ timeout: 10000 })
// Poll until condition
await expect(async () => {
const count = await page.getByTestId('count').textContent()
expect(parseInt(count!)).toBeGreaterThan(5)
}).toPass({ timeout: 10000 })
})Zaawansowane scenariusze
Autentykacja
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test'
const authFile = 'playwright/.auth/user.json'
setup('authenticate', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('user@example.com')
await page.getByLabel('Password').fill('password123')
await page.getByRole('button', { name: 'Sign in' }).click()
// Wait for redirect after login
await page.waitForURL('/dashboard')
// Save authentication state
await page.context().storageState({ path: authFile })
})// playwright.config.ts
export default defineConfig({
projects: [
// Setup project
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
// Tests that need auth
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: 'playwright/.auth/user.json',
},
dependencies: ['setup'],
},
],
})// tests/dashboard.spec.ts
import { test, expect } from '@playwright/test'
// Ten test używa zalogowanego stanu
test('dashboard shows user data', async ({ page }) => {
await page.goto('/dashboard')
await expect(page.getByText('Welcome, User')).toBeVisible()
})Wiele kontekstów (kilku użytkowników)
test('admin and user interaction', async ({ browser }) => {
// Admin context
const adminContext = await browser.newContext({
storageState: 'playwright/.auth/admin.json'
})
const adminPage = await adminContext.newPage()
// User context
const userContext = await browser.newContext({
storageState: 'playwright/.auth/user.json'
})
const userPage = await userContext.newPage()
// Admin creates something
await adminPage.goto('/admin/posts')
await adminPage.getByRole('button', { name: 'New Post' }).click()
await adminPage.getByLabel('Title').fill('Test Post')
await adminPage.getByRole('button', { name: 'Publish' }).click()
// User sees it
await userPage.goto('/posts')
await expect(userPage.getByText('Test Post')).toBeVisible()
// Cleanup
await adminContext.close()
await userContext.close()
})Testowanie API
import { test, expect } from '@playwright/test'
test.describe('API Tests', () => {
test('GET /api/users', async ({ request }) => {
const response = await request.get('/api/users')
expect(response.ok()).toBeTruthy()
expect(response.status()).toBe(200)
const users = await response.json()
expect(users).toHaveLength(10)
expect(users[0]).toHaveProperty('email')
})
test('POST /api/users', async ({ request }) => {
const response = await request.post('/api/users', {
data: {
name: 'John Doe',
email: 'john@example.com'
}
})
expect(response.status()).toBe(201)
const user = await response.json()
expect(user.name).toBe('John Doe')
})
test('with authentication', async ({ request }) => {
const response = await request.get('/api/me', {
headers: {
'Authorization': `Bearer ${process.env.API_TOKEN}`
}
})
expect(response.ok()).toBeTruthy()
})
})Testy regresji wizualnej
import { test, expect } from '@playwright/test'
test('visual regression', async ({ page }) => {
await page.goto('/dashboard')
// Full page screenshot
await expect(page).toHaveScreenshot('dashboard.png')
// Element screenshot
const chart = page.getByTestId('revenue-chart')
await expect(chart).toHaveScreenshot('revenue-chart.png')
// With options
await expect(page).toHaveScreenshot('dashboard-full.png', {
fullPage: true,
maxDiffPixels: 100,
threshold: 0.2,
})
})# Aktualizacja baseline screenshots
npx playwright test --update-snapshotsMock i intercept
test('mock API responses', async ({ page }) => {
// Mock konkretnego endpointu
await page.route('**/api/users', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([
{ id: 1, name: 'Mock User' }
])
})
})
await page.goto('/users')
await expect(page.getByText('Mock User')).toBeVisible()
})
test('modify response', async ({ page }) => {
await page.route('**/api/products', async route => {
const response = await route.fetch()
const json = await response.json()
// Modify response
json.products = json.products.slice(0, 3)
await route.fulfill({ response, json })
})
await page.goto('/products')
})
test('abort requests', async ({ page }) => {
// Block images
await page.route('**/*.{png,jpg,jpeg}', route => route.abort())
// Block analytics
await page.route('**/analytics/**', route => route.abort())
await page.goto('/page')
})
test('delay response', async ({ page }) => {
await page.route('**/api/slow', async route => {
await new Promise(resolve => setTimeout(resolve, 3000))
await route.continue()
})
await page.goto('/page')
// Test loading state
await expect(page.getByText('Loading...')).toBeVisible()
})Fixtures (własne przygotowanie)
// fixtures.ts
import { test as base, expect } from '@playwright/test'
// Extend base test with custom fixtures
export const test = base.extend<{
adminPage: Page
userPage: Page
todoApp: TodoApp
}>({
// Admin page fixture
adminPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: 'playwright/.auth/admin.json'
})
const page = await context.newPage()
await use(page)
await context.close()
},
// User page fixture
userPage: async ({ browser }, use) => {
const context = await browser.newContext({
storageState: 'playwright/.auth/user.json'
})
const page = await context.newPage()
await use(page)
await context.close()
},
// Page Object fixture
todoApp: async ({ page }, use) => {
const todoApp = new TodoApp(page)
await todoApp.goto()
await use(todoApp)
},
})
export { expect }
// Page Object Model
class TodoApp {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/todos')
}
async addTodo(text: string) {
await this.page.getByPlaceholder('What needs to be done?').fill(text)
await this.page.keyboard.press('Enter')
}
async toggleTodo(text: string) {
await this.page.getByRole('listitem')
.filter({ hasText: text })
.getByRole('checkbox')
.click()
}
async deleteTodo(text: string) {
await this.page.getByRole('listitem')
.filter({ hasText: text })
.hover()
await this.page.getByRole('button', { name: 'Delete' }).click()
}
async getTodoCount() {
return await this.page.getByRole('listitem').count()
}
}// tests/todo.spec.ts
import { test, expect } from '../fixtures'
test('can add and complete todos', async ({ todoApp }) => {
await todoApp.addTodo('Buy groceries')
await todoApp.addTodo('Do laundry')
expect(await todoApp.getTodoCount()).toBe(2)
await todoApp.toggleTodo('Buy groceries')
await todoApp.deleteTodo('Do laundry')
expect(await todoApp.getTodoCount()).toBe(1)
})Uruchamianie testów
Podstawowe komendy
# Uruchom wszystkie testy
npx playwright test
# Konkretny plik
npx playwright test tests/login.spec.ts
# Konkretny test (grep)
npx playwright test -g "login works"
# Konkretna przeglądarka
npx playwright test --project=chromium
npx playwright test --project=firefox
# Headed mode (widoczna przeglądarka)
npx playwright test --headed
# Debug mode
npx playwright test --debug
# UI mode (interaktywny)
npx playwright test --ui
# Tylko failed testy
npx playwright test --last-failed
# Powtórz test X razy
npx playwright test --repeat-each=3Raporty
# HTML report
npx playwright show-report
# Trace viewer
npx playwright show-trace trace.zipCodegen - nagrywanie testów
# Nagraj interakcje i wygeneruj kod
npx playwright codegen https://example.com
# Z emulacją urządzenia
npx playwright codegen --device="iPhone 13"
# Z viewport
npx playwright codegen --viewport-size=800,600
# Zapisz do pliku
npx playwright codegen -o tests/recorded.spec.tsIntegracja z potokiem budowania
GitHub Actions
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 18
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30Sharding (równoległe CI)
jobs:
test:
strategy:
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- name: Run tests
run: npx playwright test --shard=${{ matrix.shard }}Dobre praktyki
Stabilne selektory
// Dobrze - semantyczne, stabilne
page.getByRole('button', { name: 'Submit' })
page.getByLabel('Email')
page.getByTestId('login-form')
// Zle - kruche, zalezne od implementacji
page.locator('.btn-primary')
page.locator('#submit-btn')
page.locator('div > button:nth-child(2)')Izolacja testów
// Dobrze - kazdy test niezalezny
test.beforeEach(async ({ page }) => {
// Reset state przed każdym testem
await page.goto('/')
})
// Zle - zaleznosc miedzy testami
// Test A nie powinien polegać na stanie z Test BTest ID dla trudnych elementów
<!-- W aplikacji -->
<button data-testid="checkout-button">Complete Purchase</button>// W teście
await page.getByTestId('checkout-button').click()FAQ - Najczęściej zadawane pytania
Jak debugować failed testy?
- Uruchom z
--debug:npx playwright test --debug - Użyj Trace Viewer do analizy trace.zip
- Dodaj
await page.pause()w kodzie testu - Użyj UI mode:
npx playwright test --ui
Jak testować responsywność?
test('mobile layout', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 667 })
await page.goto('/')
await expect(page.getByRole('button', { name: 'Menu' })).toBeVisible()
})Jak obsłużyć popup/dialog?
page.on('dialog', dialog => dialog.accept())
await page.getByRole('button', { name: 'Delete' }).click()Jak testować nowe okno/tab?
const [newPage] = await Promise.all([
page.waitForEvent('popup'),
page.getByRole('link', { name: 'Open in new tab' }).click()
])
await expect(newPage).toHaveURL('/new-page')Testy migotliwe, czyli największy wróg
Mechanizm automatycznego oczekiwania usuwa najczęstsze przyczyny losowych porażek, ale nie wszystkie. Warto znać te, które zostają, bo to one psują zaufanie zespołu do zestawu testów, a bez zaufania nikt tych testów nie czyta.
Pierwsza to współdzielony stan. Testy wykonują się równolegle, więc dwa z nich operujące na tym samym rekordzie w bazie będą się nawzajem psuć w sposób zależny od kolejności. Rozwiązaniem jest, żeby każdy test tworzył własne dane i po sobie sprzątał, a nie polegał na tym, co zostawił poprzedni.
Druga to zależność od czasu rzeczywistego. Test sprawdzający, czy powiadomienie znika po pięciu sekundach, będzie przechodził na szybkiej maszynie i zawodził na obciążonym serwerze budującym. Zamiast czekać na upływ czasu, warto sterować zegarem po stronie przeglądarki albo sprawdzać skutek, a nie moment.
Trzecia to zewnętrzne usługi. Test wołający prawdziwe API płatności zawiedzie w dniu, w którym ta usługa ma awarię, i słusznie, ale nie o to chodziło w tym teście. Przechwytywanie żądań i zwracanie ustalonej odpowiedzi zamyka temat.
Czwarta to animacje. Element w trakcie przejścia bywa technicznie widoczny, a wizualnie jeszcze nie na miejscu, co psuje zwłaszcza porównania zrzutów ekranu. Wyłączenie animacji na czas testów jest tu standardową praktyką.
Gdy test już zacznie migotać, nie wyłączaj go i nie dodawaj ponowień w nadziei, że przejdzie za trzecim razem. Ponowienia mają sens jako siatka bezpieczeństwa, a nie jako sposób ukrywania problemu, bo test przechodzący raz na trzy próby przestaje cokolwiek gwarantować.
Koszt i kiedy nie pisać testów w przeglądarce
Testy end to end są najdroższe w całej piramidzie testów i warto to uwzględnić przy planowaniu, zamiast pisać je odruchowo do wszystkiego.
Kosztują czas wykonania. Uruchomienie prawdziwej przeglądarki, załadowanie aplikacji i przejście przez interfejs trwa sekundy, podczas gdy test jednostkowy zamyka się w milisekundach. Zestaw pięciuset takich testów potrafi wydłużyć pipeline do kilkunastu minut, a to jest czas, w którym zespół czeka.
Kosztują też utrzymanie. Każda zmiana w interfejsie potencjalnie psuje test, nawet jeśli logika działa poprawnie. Im więcej testów sprawdza szczegóły wyglądu, tym częściej naprawiasz testy zamiast pisać kod.
Praktyczna zasada, która się broni: testami w przeglądarce sprawdzaj ścieżki, na których zarabiasz albo których zepsucie zauważysz najboleśniej. Rejestracja, logowanie, złożenie zamówienia, płatność. Resztę, czyli walidację formularzy, formatowanie danych, logikę warunkową, sprawdzaj taniej i szybciej na niższym poziomie, w TypeScripcie bez uruchamiania przeglądarki.
Dwadzieścia dobrze dobranych testów end to end daje więcej pewności niż dwieście sprawdzających wszystko po kolei, bo te dwadzieścia ktoś naprawi, gdy zaczną zawodzić, a dwustu nikt nie utrzyma.
Dokumentację i pełne API opisuje strona projektu, a kod źródłowy znajdziesz w repozytorium na GitHubie.