95% unit test coverage measures isolated functions. Signup, login, and payment move through frontend, API, authentication, and session at the same time, and that path stays untested. The bugs that reach your users show up in the integration between components and in the flows nobody ran end to end.

Playwright next to Cypress

Both drive a real browser and intercept network traffic. Playwright gives you parallel execution per worker, Chromium, Firefox, and WebKit in one setup, and multiple tabs and iframes through one API. Teams already writing tests in Vitest or Jest recognize the Playwright API in the first session. Cypress remains a reference for developer experience in the Node ecosystem, and migration costs real money in both directions, so pick once.

// 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 test: the full login

The login flow validates frontend, API, authentication, and session in a single test. Start there.

// 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')
})

The Playwright best practices page asks tests to depend on attributes the user sees and to avoid the DOM structure. Preference order: role (getByRole), label, text, then test id. The locators guide confirms data-testid is the fallback when role and text cannot identify the element alone. Layout CSS classes break on the first design pass.

Page Objects for repeated selectors

Twenty tests pointing at the same login form become expensive to maintain once the markup changes. Page Objects gather the selectors into one class per 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')
})

When the frontend changes, you fix one file and the twenty tests stay green.

Error states require network interception

API timeouts, dropped connections, and loading states never show up in a unit test. page.route intercepts the call and returns the failure:

test('shows error when API is down', async ({ page }) => {
  // Intercept the API and abort the connection
  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()
})

The test asserts the message and the retry button, which is what the user sees when the backend goes down.

Test data created and deleted by the fixture

E2E needs predictable data. Playwright fixtures create the user through the API before the test and delete it after:

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

export const test = base.extend<{ testUser: User }>({
  testUser: async ({ request }, use) => {
    // Create the test user through the 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)

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

Data created per test removes dependencies between cases and makes execution order irrelevant.

CI with browsers and artifacts

The pipeline installs the browsers, runs the suite, and publishes the report when something fails:

- 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/

With trace: 'on-first-retry', CI records the session that reproduces the bug. You open the trace instead of reproducing the scenario by hand.

Where E2E stops paying off

E2E tests run slow and cost real money to maintain. John Micco, in Flaky Tests at Google and How We Mitigate Them (Google Testing Blog, 2016), reported 1.5% flaky results across Google's test corpus and some level of flakiness in 16% of tests. Ham Vocke, in The Practical Test Pyramid (martinfowler.com, 2018), argues for many unit tests, some integration tests, and few end-to-end tests. Reserve E2E for login, checkout, account creation, and payment. Everything else stays with unit and integration tests.

References