If you have 95% unit test coverage but never tested the complete flow from signup to payment, you have a false sense of security. The bugs that hurt most in production live in the integration between components, in user paths nobody tested end-to-end.

Why Playwright and not Cypress

Both are good. Playwright has practical advantages: native support for multiple tabs and iframes, parallel execution per test, more reliable auto-waiting, and support for Chromium, Firefox, and WebKit in the same setup. For teams already using Vitest or Jest, Playwright's API integrates better with the existing ecosystem.

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

export default defineConfig({
  testDir: './e2e',
  timeout: 30000,
  retries: process.env.CI ? 2 : 0,
  use: {
    baseURL: 'http://localhost:3000',
    screenshot: 'only-on-failure',
    trace: 'on-first-retry',
  },
  webServer: {
    command: 'pnpm dev',
    port: 3000,
    reuseExistingServer: !process.env.CI,
  },
})

Your first E2E test

An E2E test worth writing: the complete login flow. Not the most complex test, but it validates entire layers: frontend, API, authentication, session.

// e2e/login.spec.ts
import { test, expect } from '@playwright/test'

test('user can log in and see dashboard', async ({ page }) => {
  await page.goto('/login')

  await page.fill('[data-testid="email"]', 'user@example.com')
  await page.fill('[data-testid="password"]', 'password123')
  await page.click('[data-testid="submit"]')

  await expect(page).toHaveURL('/dashboard')
  await expect(page.locator('h1')).toContainText('Welcome')
})

Notice: data-testid as selectors, not CSS classes or visible text. Stable selectors survive UI changes.

Page Object Model: don't repeat selectors

When you have 20 tests using the same login form, duplicated selectors become heavy maintenance. Page Objects encapsulate interaction with each page:

// e2e/pages/LoginPage.ts
import { Page, expect } from '@playwright/test'

export class LoginPage {
  constructor(private readonly page: Page) {}

  async goto() {
    await this.page.goto('/login')
  }

  async login(email: string, password: string) {
    await this.page.fill('[data-testid="email"]', email)
    await this.page.fill('[data-testid="password"]', password)
    await this.page.click('[data-testid="submit"]')
  }

  async expectError(message: string) {
    await expect(this.page.locator('[data-testid="error"]'))
      .toContainText(message)
  }
}

// e2e/login.spec.ts
test('failed login shows error', async ({ page }) => {
  const loginPage = new LoginPage(page)
  await loginPage.goto()
  await loginPage.login('wrong@example.com', 'bad')
  await loginPage.expectError('Invalid credentials')
})

Testing error states

Where E2E adds the most value is testing error paths that unit tests don't cover: API timeouts, network errors, loading states, offline behavior.

test('shows error when API is down', async ({ page }) => {
  // Intercept API and simulate failure
  await page.route('**/api/orders', route => route.abort('connectionrefused'))

  await page.goto('/orders')

  await expect(page.locator('[data-testid="error-message"]'))
    .toContainText('Could not load orders')
  await expect(page.locator('[data-testid="retry-button"]'))
    .toBeVisible()
})

Test data: seeding and cleanup

E2E tests need consistent data. Your test data should be created before each test and removed after:

// e2e/fixtures.ts
import { test as base } from '@playwright/test'

export const test = base.extend<{ testUser: User }>({
  testUser: async ({ request }, use) => {
    // Create test user via API
    const res = await request.post('/api/test/users', {
      data: { email: `test-${Date.now()}@example.com`, password: 'test123' }
    })
    const user = await res.json()

    await use(user)

    // Cleanup
    await request.delete(`/api/test/users/${user.id}`)
  },
})

Running in CI

Playwright in CI needs specific steps: install browsers, run with --reporter=html, and upload artifacts on failure. In GitHub Actions:

- name: Install Playwright
  run: pnpm exec playwright install --with-deps

- name: Run E2E tests
  run: pnpm test:e2e

- name: Upload test report
  if: failure()
  uses: actions/upload-artifact@v4
  with:
    name: playwright-report
    path: playwright-report/

Where to stop

Don't test everything with E2E. E2E tests are slow, fragile, and expensive to maintain. Use them for critical user flows: login, checkout, account creation, payment flows. For everything else, unit and integration tests are enough.

The rule: if a failure in that flow causes revenue or data loss, use E2E. If it's a secondary feature, use unit or integration tests.