Playwright vs Cypress. Testowanie E2E po roku z każdym w produkcji
W 2023 mieliśmy w agencji trzy projekty z Cypress. Wszystkie miały ten sam problem: flaky testy, wolne CI, dev’y nie chcieli ich pisać. Testy padały losowo, nikt nie ufał wynikom.
W 2024 przetestowałem Playwright w nowym projekcie. Po tygodniu wiedziałem, że nie wrócę do Cypress. Prędkość, developer experience, integracja z CI. Wszystko dramatycznie lepsze.
Dziś w agencji wszystkie nowe projekty mają Playwright. Stare projekty na Cypress dokańczam, ale nie migruję na siłę.
Ten post to konkretne porównanie po roku z Playwright w produkcji. Nie hate na Cypress. Ale honest ocena, kto wygrywa w 2026.
Krótka odpowiedź
Dla nowych projektów w 2026: Playwright. Bezdyskusyjnie.
Powody, w kolejności ważności:
- Dramatycznie mniej flaky testów (auto-waiting działa lepiej)
- 2-3x szybsze CI (parallel execution out of the box)
- Multi-browser out of the box (Chromium, Firefox, WebKit)
- Lepsze debugowanie (trace viewer)
- Pisany w TypeScript, natywnie typesafe
- Backing Microsoft (stable long-term)
Cypress nadal działa, nadal ma community, nadal dostaje update’y. Ale przewaga Playwright jest tak duża, że dla nowych projektów nie ma dyskusji.
Co to jest E2E testing
E2E (End-to-End) testy uruchamiają Twoją aplikację jak prawdziwy user. Otwierają browser, klikają, wypełniają formy, sprawdzają wyniki. W przeciwieństwie do unit testów (jedna funkcja) i integration (kilka funkcji), E2E testuje cały user flow.
Wartość: łapiesz bugi, które unit testy nie widzą. Broken routing, zły data flow między komponentami, integracja z API.
Cena: wolniejsze niż inne testy (sekundy vs milisekundy). Bardziej flaky. Wymagają real browser.
Dla większości projektów mixed testing pyramid:
- 70% unit testy (Vitest, pisałem o tym w Vitest)
- 20% integration
- 10% E2E
E2E dla critical user flows: login, checkout, główna funkcjonalność. Nie dla każdego przycisku.
Playwright: co robi lepiej
Auto-waiting. Playwright czeka automatycznie, aż element będzie visible, enabled, stable. Zamiast:
// Cypress old style
cy.wait(500) // pray it's enough
cy.get('button').click()
Piszesz:
// Playwright
await page.getByRole('button', { name: 'Save' }).click()
// waits automatically
Rezultat: znacznie mniej flaky testów. Bez wait, sleep, retryUntil boilerplate.
Multi-browser. Chromium, Firefox, WebKit. Same tests, different browsers. Cypress do niedawna tylko Chromium (dodali Firefox, ale WebKit nadal problem).
Parallel execution. --workers=4 uruchamia 4 testy równolegle. Cypress też ma, ale w płatnej wersji Cloud.
Trace viewer. Test padnie? Otwierasz trace file (open source tool), widzisz każdą akcję krok po kroku, screenshoty, network requests, console logi. Debug w 30 sekund zamiast 15 minut.
TypeScript native. Napisany w TypeScript, świetne autocomplete, typesafe API.
Codegen. Playwright potrafi generować kod testu z klikania w browser. Otwiera browser, klikasz, on generuje testy. Świetne dla juniorów.
API test’y. Playwright może testować REST API bez browsera. Cypress też, ale mniej wygodnie.
Cypress: co robi lepiej
Dla balansu, gdzie Cypress nadal wygrywa.
GUI. Cypress Test Runner (Cypress App) jest ładny, intuicyjny. Playwright ma UI Mode, ale Cypress’owy nadal bardziej dopracowany.
Ecosystem plugins. Cypress starszy, więcej plugin’ów community. Playwright dogania, ale mniej.
Learning curve dla juniorów. Cypress API było ergonomiczne od dnia 1. Playwright wymagał więcej nauki (async/await, locators). W 2026 różnica jest mniejsza.
Cypress Cloud. Managed service do storage testów, insights. Playwright ma alternatywy (Report Portal, Currents), ale native support słabszy.
Dla większości projektów te przewagi są mniejsze niż przewagi Playwright.
Performance: konkretne liczby
Real world project, ~80 E2E testów.
Cypress (Chrome, headless, single machine):
- Cold run: 12 minut
- Warm run (with cache): 10 minut
- Flakiness rate: 5-8%
Playwright (Chromium, headless, single machine, sequential):
- Cold run: 6 minut
- Warm run: 5 minut
- Flakiness rate: 1-2%
Playwright (Chromium, headless, 4 workers):
- Cold run: 2 minuty
- Warm run: 90 sekund
- Flakiness rate: 1-2%
Playwright z parallel execution jest 6-8x szybszy od Cypress. Plus mniej flaky.
W CI/CD to różnica między CI zajmującym 20 minut (blokada developerów) a 5 minut (chwila kawy).
Setup Playwright
Instalacja w projekcie Next.js:
pnpm add -D @playwright/test
pnpm exec playwright install
playwright install pobiera Chromium, Firefox, WebKit. ~500 MB, ale one-time.
Config w playwright.config.ts:
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 4 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'mobile', use: { ...devices['iPhone 15'] } },
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
Kluczowe rzeczy:
- fullyParallel: testy w tym samym pliku równolegle (Playwright robi to bezpiecznie)
- retries in CI: flaky testy retry 2x, ignoruj tymczasowe fails
- trace on-first-retry: trace file dla failed testów
- webServer: Playwright uruchamia dev server, testy uderzają w prawdziwą app
Mój pierwszy test
Standardowy login flow:
// tests/e2e/login.spec.ts
import { test, expect } from '@playwright/test'
test.describe('Login flow', () => {
test('user can log in with valid credentials', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('[email protected]')
await page.getByLabel('Password').fill('validpassword123')
await page.getByRole('button', { name: 'Sign in' }).click()
await expect(page).toHaveURL('/dashboard')
await expect(page.getByText('Welcome back')).toBeVisible()
})
test('shows error for invalid credentials', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('[email protected]')
await page.getByLabel('Password').fill('wrongpassword')
await page.getByRole('button', { name: 'Sign in' }).click()
await expect(page.getByText('Invalid credentials')).toBeVisible()
await expect(page).toHaveURL('/login')
})
})
Kluczowe conventions:
- Locators przez role/label (
getByRole,getByLabel), nie przez CSS selectors. Bliżej testowania jak user vs implementation details. - Async/await w każdej akcji.
- Explicit assertions (
toHaveURL,toBeVisible). Auto-waiting wbudowany. test.describedla grupowania.
Page Object Model
Dla większych test suite Page Object Model pomaga w reusability.
// tests/e2e/pages/login-page.ts
import type { Page } from '@playwright/test'
export class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto('/login')
}
async login(email: string, password: string) {
await this.page.getByLabel('Email').fill(email)
await this.page.getByLabel('Password').fill(password)
await this.page.getByRole('button', { name: 'Sign in' }).click()
}
async expectError(message: string) {
await expect(this.page.getByText(message)).toBeVisible()
}
}
Użycie:
test('login flow', async ({ page }) => {
const login = new LoginPage(page)
await login.goto()
await login.login('[email protected]', 'wrong')
await login.expectError('Invalid credentials')
})
Test’y stają się deklaratywne. Change UI = update Page Object, testy się nie zmieniają.
Fixtures: własne test contexts
Reusable setup dla testów:
// tests/e2e/fixtures.ts
import { test as base } from '@playwright/test'
import { LoginPage } from './pages/login-page'
type Fixtures = {
loginPage: LoginPage
authenticatedPage: Page
}
export const test = base.extend<Fixtures>({
loginPage: async ({ page }, use) => {
const loginPage = new LoginPage(page)
await use(loginPage)
},
authenticatedPage: async ({ page }, use) => {
await page.goto('/login')
await page.getByLabel('Email').fill('[email protected]')
await page.getByLabel('Password').fill('validpassword')
await page.getByRole('button', { name: 'Sign in' }).click()
await page.waitForURL('/dashboard')
await use(page)
},
})
export { expect } from '@playwright/test'
Użycie:
import { test } from './fixtures'
test('dashboard shows user data', async ({ authenticatedPage }) => {
await expect(authenticatedPage.getByText('Welcome')).toBeVisible()
})
DRY setup. Testy fokusują się na assertions, nie na boilerplate.
Testowanie z databasem
E2E testy zwykle wymagają real DB. Trzeba za nią zarządzać.
Opcja 1: reset DB przed każdym testem (safest, slowest):
test.beforeEach(async () => {
await execAsync('pnpm db:reset')
await execAsync('pnpm db:seed')
})
Opcja 2: transaction rollback (fast, complex):
Każdy test w transaction, rollback po. Wymaga backend support.
Opcja 3: parallel dbs (best for parallel testy):
Każdy worker ma swoją DB. Playwright config:
use: {
extraHTTPHeaders: {
'x-db-name': `test_${process.env.TEST_WORKER_INDEX}`,
},
}
Backend routing requestów do odpowiedniej DB per worker.
Wybór zależy od stacka. Dla większości: opcja 1 dla safety, opcja 2 dla szybkości.
Testowanie API razem z UI
Playwright może uderzać w API directly:
test('API returns user data', async ({ request }) => {
const response = await request.get('/api/users/1')
expect(response.ok()).toBeTruthy()
const user = await response.json()
expect(user.email).toBe('[email protected]')
})
Świetne dla:
- Setup danych przed UI test (create user via API, test flow via UI)
- Cleanup po testach
- Fast integration testy bez browsera
CI/CD integration
Playwright w GitHub Actions:
name: E2E Tests
on: [push, pull_request]
jobs:
e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v3
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
- run: pnpm exec playwright install --with-deps chromium
- name: Run Playwright tests
run: pnpm exec playwright test
env:
CI: true
- name: Upload traces
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-traces
path: test-results/
retention-days: 30
Kluczowe:
- –with-deps: instaluje system dependencies (libs dla Chromium)
- Upload traces: failed testy mają trace file, downloading z Actions
- retention-days: traces czasowo, żeby nie pochłonąć storage
Debug: trace viewer
Killer feature Playwright. Test padnie? playwright show-trace test-results/traces.zip otwiera GUI.
Widzisz:
- Każdą akcję (click, fill) z timeline
- Screenshoty przed i po
- Console messages
- Network requests
- DOM snapshot (klikasz akcję, widzisz page w tym momencie)
Debug testu który padnie na CI trwa 30 sekund zamiast 15 minut manualnego przekopywania.
Częste błędy
Selektory implementation-heavy. page.locator('.MuiButton-root-45f9e8'). Change library version = testy padają. Używaj getByRole, getByLabel, getByText.
Brak async/await. Testy jak page.click() (bez await) uruchamiają się w losowej kolejności. Zawsze await.
Hard sleeps. await page.waitForTimeout(2000). Flaky. Auto-waiting robi to za Ciebie w większości przypadków.
Testowanie za dużo. 200 testów E2E dla każdej opcji formy. Za wolne, za flaky. Test critical paths, unit test details.
Brak retries w CI. Nawet dobre testy sporadycznie fail (network glitch, race condition). retries: 2 w CI eliminuje 90% false positives.
Testowanie na produkcji. Playwright łatwo skonfigurować, żeby testował live produkcyjną URL. Nie rób tego. Zniszczysz data. Osobny staging environment.
Ignore failing tests. test.skip() na wieki. “Fixuj potem”. Nigdy nie fixujesz. Fix albo delete.
Migracja z Cypress
Jeśli masz projekt na Cypress i myślisz o Playwright:
Nie migruj wszystkiego naraz. Wybierz kilka critical testów, przepisz na Playwright, uruchamiaj oba równolegle. Jak Playwright działa, dopisuj do niego nowe testy. Cypress testy naturalnie umierają.
Cypress do Playwright translator: kilka tools online tłumaczy syntax. Zwykle 60-70% dokładnie, resztę ręcznie.
Migracja jednego pełnego suite’u: 2-5 dni dla 100 testów. Zwykle nie warto, chyba że projekt dużo się rozwija.
Alternatywy dla Playwright/Cypress
Selenium. Klasyk. Dla legacy projektów, cross-language support. Slow, painful DX.
Puppeteer. Chrome-only headless browser. Więcej dla scripting, mniej dla test framework.
WebdriverIO. Selenium-based, ale friendlier API. Dla teams z heavy Selenium history.
TestCafe. Zamiast browser drivera używa proxy. Prostszy setup, ale mniejsza społeczność.
Dla 90% projektów w 2026 Playwright jest wyborem. Reszta ma niche use cases.
Co czytać dalej
- Playwright docs. Świetne, up-to-date.
- Playwright examples repo. Real world testy dla różnych stacków.
- Debbie O’Brien (Microsoft): najlepszy edukator Playwright na YouTube.
Nie kupuj kursów. Docs plus one real project = wystarczy.
Od czego zacząć
Jeśli nie masz jeszcze E2E testów:
- Zainstaluj Playwright w istniejącym projekcie.
- Napisz jeden test dla critical flow (login lub główna funkcjonalność).
- Uruchom w CI.
- Po tygodniu dodaj 3-5 więcej testów.
- Po miesiącu wprowadź Page Object Model i fixtures.
- Cel po 3 miesiącach: 15-30 E2E testów pokrywających critical paths.
E2E testy nie muszą być perfekcyjne. Muszą łapać najgorsze bugi zanim trafią do usera. To wystarcza.
Testy to inwestycja. Płacisz teraz (2-3h per test), oszczędzasz potem (0.1-0.5h per prevented bug w produkcji). Break-even po miesiącu, nadal profit przez resztę życia projektu.
Napisałem osobno o Vitest dla unit testów. Vitest + Playwright to mój standard testing stack dla 2026. Fast, reliable, DX-friendly.
Powiązane wpisy
-
Vitest. Dlaczego przeszedłem z Jest i co się zmieniło w produktywności testów
Jest był standardem od 2016. Napisałem w nim tysiące testów. Był dobry na swój czas. W 2026 nie jest dobry na dziś. ...