Playwright Testing: Complete Guide with Examples

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 WebDriverWait needed
  • 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:

  1. Creates playwright.config.ts
  2. Installs browsers (Chromium, Firefox, WebKit)
  3. Creates an example test
  4. 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.

// 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()
// 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: true in 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.

Read more