← Back to articles

How to Set Up End-to-End Testing (2026)

E2E tests verify your app works from the user's perspective — clicking buttons, filling forms, navigating pages. Playwright is the standard in 2026.

Setup Playwright

npm init playwright@latest

This creates:

  • playwright.config.ts — configuration
  • tests/ — test directory
  • .github/workflows/playwright.yml — CI pipeline

Your First Test

// tests/home.spec.ts
import { test, expect } from '@playwright/test'

test('homepage loads and shows title', async ({ page }) => {
  await page.goto('/')
  await expect(page).toHaveTitle(/My App/)
  await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible()
})

Common Test Patterns

Form Submission

test('user can sign up', async ({ page }) => {
  await page.goto('/signup')
  await page.getByLabel('Email').fill('test@example.com')
  await page.getByLabel('Password').fill('SecurePass123!')
  await page.getByRole('button', { name: 'Sign Up' }).click()
  await expect(page).toHaveURL('/dashboard')
  await expect(page.getByText('Welcome, test@example.com')).toBeVisible()
})

Navigation

test('navigation works', async ({ page }) => {
  await page.goto('/')
  await page.getByRole('link', { name: 'Blog' }).click()
  await expect(page).toHaveURL('/blog')
  await expect(page.getByRole('heading', { name: 'Blog' })).toBeVisible()
})

Authentication Flow

test('login and access dashboard', async ({ page }) => {
  // Login
  await page.goto('/login')
  await page.getByLabel('Email').fill('user@example.com')
  await page.getByLabel('Password').fill('password')
  await page.getByRole('button', { name: 'Log In' }).click()

  // Verify dashboard
  await expect(page).toHaveURL('/dashboard')
  await expect(page.getByText('Dashboard')).toBeVisible()

  // Verify auth persists
  await page.goto('/settings')
  await expect(page.getByText('Account Settings')).toBeVisible()
})

Reusable Auth State

// tests/auth.setup.ts
import { test as setup } from '@playwright/test'

setup('authenticate', async ({ page }) => {
  await page.goto('/login')
  await page.getByLabel('Email').fill('user@example.com')
  await page.getByLabel('Password').fill('password')
  await page.getByRole('button', { name: 'Log In' }).click()
  await page.waitForURL('/dashboard')
  
  // Save auth state
  await page.context().storageState({ path: '.auth/user.json' })
})
// playwright.config.ts
export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { storageState: '.auth/user.json' },
      dependencies: ['setup'],
    },
  ],
})

Configuration

// playwright.config.ts
import { defineConfig } from '@playwright/test'

export default defineConfig({
  testDir: './tests',
  fullyParallel: true,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
  },

  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
})

CI Integration (GitHub Actions)

name: E2E Tests
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/

Best Practices

  1. Test user flows, not implementation. Test "user can create a post," not "button has class X."
  2. Use role-based selectors. getByRole('button') over page.locator('.btn').
  3. Don't test everything E2E. Unit tests for logic, E2E for critical user flows.
  4. Keep tests independent. Each test should work in isolation.
  5. Use test fixtures. Seed data for each test, clean up after.

FAQ

How many E2E tests should I write?

Cover critical paths: signup, login, core feature, payment. 10-30 E2E tests cover most apps. Don't test every edge case with E2E.

Playwright vs Cypress?

Playwright: faster, multi-browser, better for modern apps. Cypress: easier learning curve, better debugging UI. Choose Playwright for new projects.

How do I handle flaky tests?

Add retries in CI (retries: 2). Use await expect() instead of fixed waits. Check for race conditions.

Bottom Line

Install Playwright, write tests for critical user flows, run in CI. Start with 5-10 tests covering signup, login, and core features. Use role-based selectors and auth state reuse. The Playwright HTML reporter makes debugging failures painless.

Get AI tool guides in your inbox

Weekly deep-dives on the best AI coding tools, automation platforms, and productivity software.