Playwright in 2026: The Default E2E Framework
Playwright has become the standard for end-to-end testing in the JavaScript ecosystem. The key advantages: true multi-browser support (Chromium, Firefox, WebKit), auto-wait mechanism that eliminates most flakiness, first-class TypeScript support, and a testing model that closely matches how real users interact with browsers.
Installation and Configuration
npm init playwright@latest
# Interactive: choose TypeScript, browser selection, CI config
// 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 ? 2 : undefined,
reporter: [
['html'],
['github'],
['json', { outputFile: 'results.json' }],
],
use: {
baseURL: process.env.BASE_URL || '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'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
{ name: 'mobile-safari', use: { ...devices['iPhone 13'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
})
The Page Object Model
The Page Object Model (POM) is the most important pattern for maintainable E2E tests. Each page of your app gets a class that encapsulates selectors and actions. When the UI changes, you update the page object — not 20 tests.
// tests/e2e/pages/LoginPage.ts
import { Page, expect } from '@playwright/test'
export class LoginPage {
private readonly emailInput = this.page.getByLabel('Email')
private readonly passwordInput = this.page.getByLabel('Password')
private readonly submitButton = this.page.getByRole('button', { name: 'Sign in' })
private readonly errorMessage = this.page.getByRole('alert')
constructor(private readonly page: Page) {}
async goto() {
await this.page.goto('/login')
await expect(this.submitButton).toBeVisible()
}
async login(email: string, password: string) {
await this.emailInput.fill(email)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
async expectErrorMessage(message: string) {
await expect(this.errorMessage).toBeVisible()
await expect(this.errorMessage).toContainText(message)
}
}
// tests/e2e/auth.spec.ts
import { test, expect } from '@playwright/test'
import { LoginPage } from './pages/LoginPage'
test.describe('Authentication', () => {
let loginPage: LoginPage
test.beforeEach(async ({ page }) => {
loginPage = new LoginPage(page)
await loginPage.goto()
})
test('logs in with valid credentials', async ({ page }) => {
await loginPage.login('user@example.com', 'correct-password')
await expect(page).toHaveURL('/dashboard')
})
test('shows error for invalid password', async () => {
await loginPage.login('user@example.com', 'wrong-password')
await loginPage.expectErrorMessage('Invalid email or password')
})
test('redirects to originally requested page after login', async ({ page }) => {
await page.goto('/settings/profile')
await loginPage.login('user@example.com', 'correct-password')
await expect(page).toHaveURL('/settings/profile')
})
})
Network Interception
import { test, expect } from '@playwright/test'
test.describe('API error handling', () => {
test('shows error state when API returns 500', async ({ page }) => {
await page.route('**/api/dashboard/stats', route => {
route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({ error: 'Internal server error' }),
})
})
await page.goto('/dashboard')
await expect(page.getByRole('alert')).toContainText('Failed to load statistics')
})
test('handles network abort gracefully', async ({ page }) => {
await page.route('**/api/users', route => route.abort('timedout'))
await page.goto('/users')
await expect(page.getByText('Connection error. Please retry.')).toBeVisible()
})
test('intercepts and modifies response data', async ({ page }) => {
await page.route('**/api/user/profile', async route => {
const response = await route.fetch()
const body = await response.json()
body.subscription = 'enterprise'
body.trialExpired = true
route.fulfill({ response, body: JSON.stringify(body) })
})
await page.goto('/settings')
await expect(page.getByText('Enterprise Plan')).toBeVisible()
})
})
Visual Regression Testing
import { test, expect } from '@playwright/test'
test.describe('Visual regression', () => {
test('homepage matches snapshot', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
maxDiffPixels: 100,
})
})
test('button states match snapshots', async ({ page }) => {
await page.goto('/ui-components')
const btn = page.getByTestId('primary-button')
await expect(btn).toHaveScreenshot('button-default.png')
await btn.hover()
await expect(btn).toHaveScreenshot('button-hover.png')
})
})
Update snapshots after design changes: npx playwright test --update-snapshots
Authentication State Sharing
Re-logging in before every authenticated test is slow. Playwright's storage state feature saves sessions:
// tests/e2e/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(process.env.TEST_USER_EMAIL!)
await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!)
await page.getByRole('button', { name: 'Sign in' }).click()
await expect(page).toHaveURL('/dashboard')
await page.context().storageState({ path: authFile })
})
// playwright.config.ts — use saved auth state
projects: [
{ name: 'setup', testMatch: '**/auth.setup.ts' },
{
name: 'authenticated-tests',
use: { storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
],
CI/CD Integration
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main, develop]
jobs:
test:
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test
env:
BASE_URL: ${{ secrets.STAGING_URL }}
TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
path: playwright-report/
retention-days: 30
Debugging Failing Tests
# Headed mode — see the browser
npx playwright test --headed
# Interactive debugger with DevTools
npx playwright test --debug
# Open HTML report
npx playwright show-report
# View trace from CI failure
npx playwright show-trace trace.zip
The trace viewer shows a full timeline: every action, screenshot, network request, and console message. Debugging CI-only failures becomes dramatically faster.
Performance Tips for Large Test Suites
Sharding: Split across multiple CI machines for very large suites:
npx playwright test --shard=1/3 # CI Job 1
npx playwright test --shard=2/3 # CI Job 2
npx playwright test --shard=3/3 # CI Job 3
Selective execution: Run only tests matching a pattern during development:
npx playwright test --grep "authentication"
Playwright's combination of reliability, speed, and tooling makes it the right choice for any project that takes E2E testing seriously.