Playwright Testing: Complete Guide with Examples
Playwright is Microsoft's end-to-end testing framework for web applications. It's the best browser automation tool available in 2026 — faster than Selenium, more capable than Cypress, and works across Chromium, Firefox, and WebKit. This guide covers everything from installation to CI/CD, with real code examples throughout.
Key Takeaways
Playwright auto-waits for elements. You don't write explicit waits. Playwright automatically retries locator queries until elements are visible, attached, and actionable. This eliminates most of the flakiness that plagues Selenium tests.
Locators are the core concept. page.locator() finds elements. Use accessible locators first (role, label, placeholder, text) before falling back to CSS or XPath. Accessible locators are more resilient to UI changes.
Use the Playwright Codegen to bootstrap tests. Run npx playwright codegen https://your-app.com to record interactions as code. It's not perfect, but it's 10x faster than writing from scratch.
Run tests in parallel by default. Playwright shards tests across workers automatically. A 60-test suite that takes 5 minutes serially finishes in 30 seconds with 10 workers.
Store authentication state. Use storageState to save and restore login sessions. Your tests skip the login flow and start already authenticated — faster and more reliable.
What Is Playwright?
Playwright is an open-source end-to-end testing framework developed by Microsoft. It automates Chromium, Firefox, and WebKit browsers using a single API. You write a test once, and it runs across all three browser engines.
Playwright was released in 2020 by the same team that built Puppeteer at Google. It's now the most popular choice for new E2E testing projects, having surpassed Selenium in developer preference in most surveys.
Why Playwright Over Selenium or Cypress?
vs Selenium:
- No WebDriver protocol — uses CDP (Chrome DevTools Protocol) directly, which is faster and more reliable
- Auto-waiting built in — no explicit
WebDriverWaitneeded - Better cross-browser support (WebKit/Safari is notoriously hard with Selenium)
- Modern async API vs Selenium's older synchronous design
vs Cypress:
- Runs tests outside the browser, enabling multi-tab, multi-origin testing
- Supports Firefox and WebKit (Cypress is Chromium-only)
- Better for testing auth flows, file downloads, iframes
- Slightly steeper learning curve but more powerful
Installing Playwright
npm init playwright@latest
This interactive installer:
- Creates
playwright.config.ts - Installs browsers (Chromium, Firefox, WebKit)
- Creates an example test
- Sets up a GitHub Actions workflow (optional)
For an existing project:
npm install --save-dev @playwright/test
npx playwright install
Your First Test
Create tests/example.spec.ts:
import { test, expect } from '@playwright/test'
test('homepage has correct title', async ({ page }) => {
await page.goto('https://playwright.dev')
await expect(page).toHaveTitle(/Playwright/)
})
test('get started link works', async ({ page }) => {
await page.goto('https://playwright.dev')
await page.getByRole('link', { name: 'Get started' }).click()
await expect(page).toHaveURL(/.*intro/)
})
Run it:
npx playwright test
Locators: Finding Elements
Locators are how Playwright finds elements on the page. Unlike raw CSS selectors, Playwright locators automatically retry until the element is available.
Recommended Locators (use these first)
// By role — semantic, accessible, resilient
page.getByRole('button', { name: 'Submit' })
page.getByRole('link', { name: 'Home' })
page.getByRole('textbox', { name: 'Email' })
page.getByRole('heading', { level: 1 })
// By label — good for form inputs
page.getByLabel('Email address')
page.getByLabel('Password')
// By placeholder
page.getByPlaceholder('Search...')
// By text
page.getByText('Welcome back')
// By test ID — stable, explicit (add data-testid to your elements)
page.getByTestId('submit-btn')
Fallback Locators
Use these only when semantic locators aren't available:
// CSS selector
page.locator('.submit-button')
page.locator('#main-nav a')
// XPath
page.locator('xpath=//button[@type="submit"]')
Chaining and Filtering
// Filter within a container
const product = page.locator('.product-card').filter({ hasText: 'Running Shoes' })
await product.getByRole('button', { name: 'Add to cart' }).click()
// nth element
page.locator('.product-card').nth(0) // first card
page.locator('.product-card').last() // last card
// First matching
page.locator('.product-card').first()
Assertions
Playwright includes a rich set of built-in assertions. All assertions auto-retry until the condition is met or the timeout expires (default: 5 seconds).
// Page assertions
await expect(page).toHaveURL('/dashboard')
await expect(page).toHaveTitle('Dashboard | MyApp')
// Element assertions
await expect(page.getByRole('heading')).toBeVisible()
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled()
await expect(page.getByRole('button', { name: 'Delete' })).toBeDisabled()
await expect(page.getByTestId('alert')).toHaveText('Saved successfully')
await expect(page.locator('input[name="email"]')).toHaveValue('user@example.com')
// Count
await expect(page.locator('.product-card')).toHaveCount(12)
// Negative assertions
await expect(page.getByTestId('error')).not.toBeVisible()
await expect(page.getByRole('button', { name: 'Delete' })).not.toBeDisabled()
// Snapshot testing
await expect(page).toHaveScreenshot('homepage.png')
Actions
// Clicking
await page.getByRole('button', { name: 'Submit' }).click()
await page.getByRole('link', { name: 'Home' }).click()
// Right-click, double-click
await page.locator('.item').dblclick()
await page.locator('.item').click({ button: 'right' })
// Typing
await page.getByLabel('Email').fill('user@example.com')
await page.getByLabel('Search').type('playwright', { delay: 50 }) // human-like typing
// Select
await page.getByLabel('Country').selectOption('US')
await page.getByLabel('Country').selectOption({ label: 'United States' })
// Checkboxes and radios
await page.getByLabel('I agree').check()
await page.getByLabel('I agree').uncheck()
// File upload
await page.getByLabel('Upload file').setInputFiles('path/to/file.pdf')
// Keyboard shortcuts
await page.keyboard.press('Enter')
await page.keyboard.press('Control+A')
// Hover
await page.getByTestId('dropdown-trigger').hover()
// Focus
await page.getByLabel('Email').focus()
// Clear
await page.getByLabel('Search').clear()
Navigation
// Navigate to URL
await page.goto('https://example.com')
await page.goto('/dashboard') // relative URL when baseURL is set
// Wait for navigation
await Promise.all([
page.waitForNavigation(),
page.getByRole('link', { name: 'Next page' }).click()
])
// Wait for URL to change
await page.waitForURL('/dashboard')
// Go back/forward
await page.goBack()
await page.goForward()
// Reload
await page.reload()
Authentication: Save and Reuse State
The most important Playwright optimization: save your login state once, reuse it in every test.
// auth.setup.ts — runs once before all tests
import { test as setup } from '@playwright/test'
setup('authenticate', async ({ page }) => {
await page.goto('/login')
await page.getByLabel('Email').fill('admin@example.com')
await page.getByLabel('Password').fill('password123')
await page.getByRole('button', { name: 'Sign in' }).click()
await page.waitForURL('/dashboard')
// Save auth state to file
await page.context().storageState({ path: 'playwright/.auth/admin.json' })
})
// playwright.config.ts
import { defineConfig } from '@playwright/test'
export default defineConfig({
projects: [
{
name: 'setup',
testMatch: /.*\.setup\.ts/
},
{
name: 'authenticated tests',
use: {
storageState: 'playwright/.auth/admin.json' // Start already logged in
},
dependencies: ['setup']
}
]
})
Now every test starts authenticated. No logging in on every test — 10x faster and no auth-related flakiness.
Parallel Test Execution
Playwright runs tests in parallel by default. Configure in playwright.config.ts:
import { defineConfig } from '@playwright/test'
export default defineConfig({
workers: process.env.CI ? 2 : undefined, // 2 workers in CI, auto in local
// Each test file runs in its own worker
fullyParallel: true,
// Retry failed tests once in CI
retries: process.env.CI ? 1 : 0,
// Timeout per test
timeout: 30000,
})
To run only specific tests:
npx playwright test --grep <span class="hljs-string">"login"
npx playwright <span class="hljs-built_in">test tests/checkout.spec.ts
npx playwright <span class="hljs-built_in">test --project=chromium
Handling Async Flows
Waiting for Network Requests
// Wait for specific API response
const responsePromise = page.waitForResponse('/api/users')
await page.getByRole('button', { name: 'Load users' }).click()
const response = await responsePromise
expect(response.status()).toBe(200)
// Wait for all network activity to settle
await page.waitForLoadState('networkidle')
Intercepting and Mocking APIs
// Mock API response
await page.route('/api/products', async route => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify([{ id: 1, name: 'Test Product', price: 29.99 }])
})
})
// Intercept and modify
await page.route('/api/checkout', async route => {
const request = route.request()
const body = request.postDataJSON()
await route.continue({ postData: { ...body, coupon: 'TEST20' } })
})
// Block requests (e.g., analytics)
await page.route('**/*.{png,jpg,jpeg,gif,webp}', route => route.abort())
Page Object Model
As your test suite grows, Page Objects keep tests maintainable:
// page-objects/LoginPage.ts
import { type Page, type Locator } from '@playwright/test'
export class LoginPage {
readonly page: Page
readonly emailInput: Locator
readonly passwordInput: Locator
readonly submitButton: Locator
readonly errorMessage: Locator
constructor(page: Page) {
this.page = page
this.emailInput = page.getByLabel('Email')
this.passwordInput = page.getByLabel('Password')
this.submitButton = page.getByRole('button', { name: 'Sign in' })
this.errorMessage = page.getByTestId('error-message')
}
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()
}
}
// tests/login.spec.ts
import { test, expect } from '@playwright/test'
import { LoginPage } from '../page-objects/LoginPage'
test('valid credentials redirect to dashboard', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('user@example.com', 'password123')
await expect(page).toHaveURL('/dashboard')
})
test('invalid credentials show error', async ({ page }) => {
const loginPage = new LoginPage(page)
await loginPage.goto()
await loginPage.login('user@example.com', 'wrongpassword')
await expect(loginPage.errorMessage).toBeVisible()
await expect(loginPage.errorMessage).toHaveText('Invalid credentials')
})
Debugging Tests
Headed Mode (watch the browser)
npx playwright test --headed
UI Mode (interactive test runner)
npx playwright test --ui
Playwright UI shows a timeline of every action, screenshots at each step, network requests, and the DOM state. The best debugging tool available for E2E tests.
Pause in Tests
test('debug this test', async ({ page }) => {
await page.goto('/checkout')
await page.pause() // Pauses here, opens browser inspector
await page.getByRole('button', { name: 'Place order' }).click()
})
Trace Viewer
Playwright automatically saves traces when tests fail (if configured):
// playwright.config.ts
export default defineConfig({
use: {
trace: 'on-first-retry', // Save trace on first retry
screenshot: 'only-on-failure',
video: 'on-first-retry'
}
})
View the trace:
npx playwright show-trace test-results/trace.zip
The trace shows every action, screenshot before/after each step, network logs, and console output. You can reproduce any failure offline.
Playwright Codegen: Record Tests
Generate test code by recording your interactions:
npx playwright codegen https://your-app.com
This opens a browser where you interact normally, and generates the test code in real-time. It's not perfect — generated tests often need cleanup — but it's much faster than writing from scratch.
CI/CD Integration
GitHub Actions
# .github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
playwright:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- 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: 30
Sharding for Speed
For large test suites, split across multiple CI machines:
strategy:
matrix:
shard: [1/4, 2/4, 3/4, 4/4]
steps:
- run: npx playwright test --shard=${{ matrix.shard }}
Configuration Reference
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests',
timeout: 30 * 1000, // 30 seconds per test
retries: 1, // Retry once on failure
workers: 4, // Parallel workers
reporter: 'html', // HTML report
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
headless: true,
},
projects: [
{
name: 'Chromium',
use: { ...devices['Desktop Chrome'] }
},
{
name: 'Firefox',
use: { ...devices['Desktop Firefox'] }
},
{
name: 'Mobile Chrome',
use: { ...devices['Pixel 5'] }
}
],
// Start dev server before running tests
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI
}
})
Common Patterns
Testing File Downloads
test('exports CSV correctly', async ({ page }) => {
const downloadPromise = page.waitForEvent('download')
await page.getByRole('button', { name: 'Export CSV' }).click()
const download = await downloadPromise
expect(download.suggestedFilename()).toBe('users-export.csv')
const path = await download.path()
// Verify file contents
})
Testing Dialogs
test('handles confirmation dialog', async ({ page }) => {
page.on('dialog', dialog => dialog.accept()) // Auto-accept
await page.getByRole('button', { name: 'Delete account' }).click()
await expect(page).toHaveURL('/goodbye')
})
Testing Multiple Tabs
test('opens link in new tab', async ({ context, page }) => {
const [newPage] = await Promise.all([
context.waitForEvent('page'),
page.getByRole('link', { name: 'Open in new tab' }).click()
])
await newPage.waitForLoadState()
await expect(newPage).toHaveURL(/external-site/)
})
Playwright vs Selenium vs Cypress
| Feature | Playwright | Selenium | Cypress |
|---|---|---|---|
| Browsers | Chrome, Firefox, WebKit | All (via drivers) | Chrome, Edge, Firefox |
| Auto-waiting | Yes | No | Yes |
| Multi-tab | Yes | Yes | No |
| Cross-origin | Yes | Yes | No |
| Speed | Fast | Slow | Fast |
| Language | JS, Python, Java, C#, .NET | Any | JS only |
| Learning curve | Medium | High | Low |
| Best for | Most E2E tests | Legacy, enterprise | Simple web apps |
Getting Started Checklist
- Install Playwright:
npm init playwright@latest - Write first test targeting your most critical flow
- Set up auth state storage (avoid repeated logins)
- Enable
fullyParallel: truein config - Add GitHub Actions workflow
- Set
trace: 'on-first-retry'for debugging
Need E2E tests but don't want to write Playwright code? HelpMeTest turns plain English descriptions into running Playwright tests.