DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

Playwright Complete Guide: E2E Testing in 2026

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
Enter fullscreen mode Exit fullscreen mode
// 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,
  },
})
Enter fullscreen mode Exit fullscreen mode
npx playwright test --ui   # interactive mode — best for writing tests
npx playwright test        # headless, all browsers
npx playwright test --debug  # step through
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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)
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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')
})
Enter fullscreen mode Exit fullscreen mode

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' })
})
Enter fullscreen mode Exit fullscreen mode
// playwright.config.ts
projects: [
  { name: 'setup', testMatch: /.*\.setup\.ts/ },
  {
    name: 'chromium',
    use: { storageState: 'tests/.auth/user.json' },
    dependencies: ['setup'],
  },
]
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)
})
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode
npx playwright test --update-snapshots  # generate baselines
npx playwright test                      # compare in CI
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Trace Viewer: Debugging CI Failures

# Download trace artifact from CI, then:
npx playwright show-trace trace.zip
Enter fullscreen mode Exit fullscreen mode

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',
}
Enter fullscreen mode Exit fullscreen mode

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')
Enter fullscreen mode Exit fullscreen mode

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)