End-to-end tests are the only tests that catch the class of bugs where every unit works but the whole thing is broken — a mis-wired form submit, a redirect that dropped a query param, a payment that silently failed. In 2026, Playwright is the tool the industry converged on, and it's genuinely good. This guide gets you from zero to a reliable, CI-running e2e suite.
What changed in 2026
- Playwright 1.4x added AI-assisted locator generation in the VS Code extension — you point, it writes a resilient
getByRole locator.
- Component testing (React/Vue/Svelte in a real browser) stabilized, blurring the line between integration and e2e.
- Trace viewer got a network panel — you can inspect every XHR/fetch call in a failing trace without a proxy.
- Cypress 14 exists but the ecosystem momentum is clearly with Playwright; most new projects start on Playwright.
@playwright/test ships built-in expect with web-specific matchers, removing the need for extra assertion libraries.
Install
npm init playwright@latest
# Installs @playwright/test, creates playwright.config.ts, adds example tests
Or manually:
npm install -D @playwright/test
npx playwright install --with-deps chromium firefox webkit
Configure
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: [['html'], ['list']],
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
})
Write a test
// e2e/checkout.spec.ts
import { test, expect } from '@playwright/test'
test('user can complete checkout', async ({ page }) => {
await page.goto('/products/widget')
await page.getByRole('button', { name: /add to cart/i }).click()
await page.getByRole('link', { name: /cart/i }).click()
await expect(page.getByText('1 item')).toBeVisible()
await page.getByRole('button', { name: /checkout/i }).click()
await page.getByLabel('Email').fill('test@example.com')
await page.getByRole('button', { name: /place order/i }).click()
await expect(page).toHaveURL(/\/confirmation/)
await expect(page.getByRole('heading', { name: /order confirmed/i })).toBeVisible()
})
Page Object Model
For large suites, POM keeps tests readable and DRY:
// e2e/pages/LoginPage.ts
import { type Page, type Locator } from '@playwright/test'
export class LoginPage {
readonly emailInput: Locator
readonly passwordInput: Locator
readonly submitButton: Locator
constructor(private page: Page) {
this.emailInput = page.getByLabel('Email')
this.passwordInput = page.getByLabel('Password')
this.submitButton = page.getByRole('button', { name: /sign in/i })
}
async login(email: string, password: string) {
await this.page.goto('/login')
await this.emailInput.fill(email)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
}
Locator quality table
| Locator type |
Resilience |
Use when |
getByRole |
Highest |
Interactive elements |
getByLabel |
High |
Form fields |
getByText |
Medium-high |
Unique text content |
getByTestId |
High |
No semantic role available |
locator('css') |
Low |
Last resort only |
locator('xpath') |
Very low |
Never |
How to pick test scope
| Scenario |
Use |
| Pure function logic |
Unit test (Vitest) |
| Component render + interaction |
Integration test |
| Full user journey (login → action → result) |
E2e (Playwright) |
| Third-party widget integration |
E2e |
| API contract |
API test (Playwright request fixture) |
CI setup
# .github/workflows/e2e.yml
- name: Install Playwright browsers
run: npx playwright install --with-deps
- name: Run e2e tests
run: npx playwright test
- name: Upload test report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
Common mistakes
Relying on CSS class names — classes change with refactors. Always prefer ARIA roles or labels.
No webServer config — tests that assume the dev server is running will pass locally and fail in CI. Use webServer in the config to start it automatically.
Sleeping instead of asserting — await page.waitForTimeout(2000) is a flakiness trap. Replace with await expect(locator).toBeVisible() which auto-waits up to the configured timeout.
One giant spec file — split by feature domain; Playwright runs files in parallel so splitting speeds up the suite.
What to skip
- Testing third-party auth redirects — mock them with Playwright's
route API or use a test-specific auth bypass.
- Visual regression for every page — pixel diffs are expensive to maintain; limit to critical brand-sensitive views.
- Running e2e in watch mode locally — the feedback loop is too slow; run unit tests in watch mode and e2e on demand.
FAQ
Playwright vs Cypress in 2026?
Playwright is faster (true parallelism across browsers), has a better trace debugger, and supports multiple browser engines. Cypress still has a friendlier UI for beginners. New projects should default to Playwright.
How do I handle authentication state?
Use storageState — log in once, save cookies/localStorage to a file, and load that state in every test that needs it. No re-login per test.
What about mobile testing?
Use devices in the projects config (devices['iPhone 15']). Playwright emulates device viewport, user-agent, and touch events.
How many e2e tests is too many?
A rough guideline: 10–30 critical journey tests run in under 3 minutes. If your suite exceeds 10 minutes, you have too many e2e tests and not enough unit/integration tests.
Where to go next