Playwright is the most capable browser automation tool in 2026 — multi-browser, parallel, with first-class TypeScript support and a trace viewer that makes debugging CI failures possible without local reproduction. This guide covers the patterns that actually hold up in real projects.
Setup
npm init playwright@latest
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests',
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
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['Pixel 7'] } },
],
webServer: {
command: 'npm run build && npm run start',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
npx playwright test --ui # interactive mode — best for writing tests
npx playwright test # headless, all browsers
npx playwright test --debug # step through
Locators: The Right Way
Playwright locators auto-retry until the element appears — never add manual waits:
// Best: role-based (survives refactors)
page.getByRole('button', { name: 'Submit' })
page.getByRole('heading', { name: 'Dashboard' })
page.getByRole('textbox', { name: 'Email' })
page.getByLabel('Password')
page.getByText('Welcome back')
page.getByPlaceholder('Search...')
page.getByTestId('submit-button') // last resort
// Filter and chain
const card = page.getByRole('article').filter({ hasText: 'Claude Code' })
const button = page.getByRole('form', { name: 'Login' }).getByRole('button')
Avoid locator('div > span:nth-child(3)') — it breaks on any markup change.
Page Object Model
// tests/pages/LoginPage.ts
import { type Page, type Locator, expect } from '@playwright/test'
export class LoginPage {
private readonly emailInput: Locator
private readonly passwordInput: Locator
private readonly submitButton: Locator
constructor(private page: Page) {
this.emailInput = page.getByLabel('Email')
this.passwordInput = page.getByLabel('Password')
this.submitButton = page.getByRole('button', { name: 'Log in' })
}
async goto() { await this.page.goto('/login') }
async login(email: string, password: string) {
await this.emailInput.fill(email)
await this.passwordInput.fill(password)
await this.submitButton.click()
}
async expectError(message: string) {
await expect(this.page.getByRole('alert')).toContainText(message)
}
}
// tests/auth.spec.ts
import { test, expect } from '@playwright/test'
import { LoginPage } from './pages/LoginPage'
test('login with valid credentials', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('user@example.com', 'validpassword')
await expect(page).toHaveURL('/dashboard')
})
test('shows error with wrong password', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('user@example.com', 'wrongpassword')
await loginPage.expectError('Invalid email or password')
})
Auth State Reuse
Log in once, reuse across all tests — saves minutes in large test suites:
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test'
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: 'Log in' }).click()
await expect(page).toHaveURL('/dashboard')
// Save cookies + localStorage
await page.context().storageState({ path: 'tests/.auth/user.json' })
})
// playwright.config.ts
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { storageState: 'tests/.auth/user.json' },
dependencies: ['setup'],
},
]
Network Interception
// Mock API response
await page.route('/api/posts', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ posts: [{ id: 1, title: 'Mock Post' }] })
})
})
// Test error state
await page.route('/api/posts', (route) => route.fulfill({ status: 500 }))
await page.goto('/blog')
await expect(page.getByText(/something went wrong/i)).toBeVisible()
// Add delay for loading state
await page.route('/api/posts', async (route) => {
await new Promise(r => setTimeout(r, 2000))
await route.continue()
})
await expect(page.getByTestId('loading-skeleton')).toBeVisible()
API Testing
// tests/api/posts.spec.ts
test('POST /api/posts creates a post', async ({ request }) => {
const response = await request.post('/api/posts', {
data: { title: 'Test Post', content: 'Content', published: false },
headers: { 'Authorization': `Bearer ${process.env.TEST_API_TOKEN}` }
})
expect(response.status()).toBe(201)
const body = await response.json()
expect(body).toHaveProperty('id')
})
test('GET /api/posts returns list', async ({ request }) => {
const response = await request.get('/api/posts')
expect(response.ok()).toBeTruthy()
const body = await response.json()
expect(Array.isArray(body.posts)).toBe(true)
})
Visual Regression
test('homepage matches snapshot', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveScreenshot('homepage.png', {
fullPage: true,
threshold: 0.02
})
})
// Element screenshot
const button = page.getByRole('button', { name: 'Subscribe' })
await expect(button).toHaveScreenshot('button-default.png')
npx playwright test --update-snapshots # generate baselines
npx playwright test # compare in CI
CI with GitHub Actions
name: Playwright Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm }
- run: npm ci
- run: npx playwright install --with-deps chromium firefox
- run: npm run build
- run: npx playwright test
env:
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
Trace Viewer: Debugging CI Failures
# Download trace artifact from CI, then:
npx playwright show-trace trace.zip
Full timeline: every action, screenshot at each step, network requests, console logs. Reproduce CI failures without rerunning.
// playwright.config.ts
use: {
trace: 'on-first-retry', // capture on retry only
screenshot: 'only-on-failure',
video: 'retain-on-failure',
}
Useful Patterns
// Assert count
await expect(page.getByRole('listitem')).toHaveCount(5)
// Not visible
await expect(page.getByRole('dialog')).not.toBeVisible()
// Soft assertions (don't stop on first failure)
await expect.soft(page.getByText('Title')).toBeVisible()
await expect.soft(page.getByText('Subtitle')).toBeVisible()
// Wait for network
await page.goto('/dashboard', { waitUntil: 'networkidle' })
// Keyboard submit
await page.fill('[name="email"]', 'test@example.com')
await page.keyboard.press('Enter')
What to Test with Playwright vs Vitest
E2E (Playwright): auth flows, core user journeys, form validation, checkout, navigation, error states, accessibility.
Unit (Vitest): components, business logic, API handlers, utilities, state management.
Good ratio: 10-20 E2E tests for critical flows + 100+ unit tests for logic. If you're adding waitForTimeout calls, that's the locator strategy that needs fixing — not more waiting.
Full article at stacknotice.com/blog/playwright-complete-guide-2026
Top comments (0)