TL;DR:

  • Playwright is the dominant E2E framework in 2026 — better parallelisation, multi-browser support, and API testing in a single tool
  • The Page Object Model remains the right abstraction for maintainable test suites; skip it early and pay for it later
  • AI-assisted test generation (GitHub Copilot, Cursor, playwright codegen) accelerates initial test creation but requires review — generated locators and assertions need human judgement

End-to-end testing has a reputation for being unreliable, slow, and expensive to maintain. That reputation was earned. But a combination of better tooling and better patterns means E2E testing in 2026 is meaningfully more productive than it was even three years ago. Playwright is the primary reason.

If you’re on Cypress and it’s working for you, stay — Cypress has improved significantly and the migration cost is real. If you’re starting fresh, or if Cypress is causing you pain at scale, Playwright is the better default.

Why Playwright Now

Playwright’s advantages over Cypress in a 2026 context:

True parallelisation: Playwright runs tests across multiple workers by default, with each worker getting an isolated browser context. Cypress’s parallel execution requires a paid plan and a separate orchestrator. For a large test suite, this is a significant CI cost difference.

Multi-browser from one install: Chrome, Firefox, and WebKit (Safari’s rendering engine) are all first-class targets in Playwright. Testing across browsers without managing multiple webdriver installations or separate CI configurations is genuinely useful.

API testing built in: request context in Playwright handles REST API calls with the same await-based interface as browser interactions. Seeding test data via API, then testing the resulting UI state, is clean and fast without pulling in a separate HTTP library.

No iframe and popup limitations: Cypress’s architecture makes cross-origin iframes and multiple tabs awkward. Playwright handles both natively — relevant for OAuth flows, payment providers, and third-party embeds.

Component testing: Playwright’s component testing (experimental but stable enough to use) runs component tests in a real browser, filling the gap between unit tests and full E2E tests for component libraries.

Project Setup

npm init playwright@latest

The interactive setup creates the project structure, installs browsers, and adds a sample test. Choose TypeScript — the type safety catches test authoring errors before you run.

The generated playwright.config.ts:

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  testDir: './tests/e2e',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 1 : undefined,
  reporter: 'html',
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
  ],
  webServer: {
    command: 'npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

Key decisions in this config:

  • webServer starts your dev server before tests run and shuts it down after. No manual server management in CI.
  • retries: 2 in CI catches timing-related flakiness without letting genuinely broken tests pass — a retry-2 failure is a real failure.
  • workers: 1 in CI when your CI runner has limited parallelism; remove this on a beefy CI machine.
  • trace: 'on-first-retry' captures a full trace (network, screenshots, console logs) when a test first fails, not on every run. Indispensable for debugging flaky CI failures without overloading storage.

The Page Object Model

Skip this section if you want your test suite to become unmaintainable within six months. Page Objects are the abstraction that keeps E2E tests from becoming write-once, maintain-never code.

The principle: a Page Object represents a page or component in your application and exposes methods that represent user actions. Tests use Page Objects rather than raw Playwright API calls. When your UI changes, you update the Page Object, not every test that uses that page.

// tests/e2e/pages/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 address');
    this.passwordInput = page.getByLabel('Password');
    this.submitButton = page.getByRole('button', { name: 'Sign in' });
    this.errorMessage = page.getByRole('alert');
  }

  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.errorMessage).toContainText(message);
  }
}
// tests/e2e/auth.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from './pages/LoginPage';

test('shows error for invalid credentials', async ({ page }) => {
  const loginPage = new LoginPage(page);
  await loginPage.goto();
  await loginPage.login('invalid@example.com', 'wrongpassword');
  await loginPage.expectError('Invalid email or password');
});

When the error message changes from “Invalid email or password” to “Incorrect credentials”, you update expectError in one place. When the password field label changes, you update this.passwordInput in one place.

Locator Strategy

Playwright’s locator priority for maintainable tests, in order:

  1. getByRole: Queries by ARIA role and accessible name. Resilient to HTML restructuring; fails loudly when accessibility attributes are wrong (a feature, not a bug).

    page.getByRole('button', { name: 'Submit order' })
    page.getByRole('textbox', { name: 'Email address' })
  2. getByLabel: For form inputs associated with a label element. Correct semantics in your HTML are rewarded here.

  3. getByText: For elements identified by their text content. Appropriate for navigation items, headings, body copy.

  4. getByTestId: data-testid attributes added specifically for testing. More brittle than role-based selectors but useful for complex components with no accessible identity.

    page.getByTestId('product-card-123')
  5. CSS and XPath selectors: Last resort. Brittle, implementation-coupled, hard to read.

The pattern to avoid: page.locator('.btn.btn-primary.submit-form'). This breaks whenever the CSS changes. page.getByRole('button', { name: 'Place order' }) is more resilient and documents intent.

Test Data Management

The most common E2E test maintenance problem is test data. Tests that depend on specific database state break when the database changes. Tests that create their own state conflict with each other in parallel runs.

Patterns that work:

API-based setup and teardown: Create test data via API calls in beforeEach, delete it in afterEach. Faster than UI-based setup, and each test gets a clean, known state.

test.beforeEach(async ({ request }) => {
  await request.post('/api/test/seed', {
    data: { scenario: 'user-with-active-subscription' }
  });
});

Unique identifiers per test run: Use Date.now() or a UUID in test data to avoid collisions between parallel workers.

const testEmail = `test-${Date.now()}@example.com`;

Test-specific user accounts: Maintain a set of user accounts for different scenarios (free plan, paid plan, admin, suspended) that tests log into rather than creating users per test. Faster, but requires those accounts to be in a known state at test start.

Database snapshots: For complex setup requirements, snapshot a known database state and restore it before the test suite. Requires infrastructure support but gives complete control over test data.

CI Integration

# .github/workflows/e2e.yml
name: E2E 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'
      
      - name: Install dependencies
        run: npm ci
      
      - name: Install Playwright browsers
        run: npx playwright install --with-deps chromium
      
      - name: Run E2E tests
        run: npx playwright test
        env:
          DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
      
      - name: Upload test report
        uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 7

Notes on this config:

  • Install only chromium in CI unless you specifically need Firefox and WebKit for cross-browser coverage. Chromium alone covers the majority of CI value.
  • --with-deps installs the OS-level dependencies for the browser. Necessary on GitHub’s Ubuntu runners.
  • Upload the HTML report artifact only on failure — this gives you traces and screenshots for debugging without storing large artifacts on every green run.

AI-Assisted Test Generation

Two practical AI inputs to E2E test writing in 2026:

playwright codegen: Record interactions in a browser, generate Playwright code. The generated code is a starting point, not finished test code. It uses CSS selectors by default (not role-based locators), doesn’t include assertions beyond navigation, and doesn’t handle dynamic data. Use it to understand Playwright’s API for a new interaction pattern; then rewrite using proper locators and add assertions.

npx playwright codegen https://localhost:3000

LLM-assisted authoring: Cursor, GitHub Copilot, and Claude Code are all effective at generating Page Object classes and test methods given a description of the scenario and examples of your existing patterns. Feed the LLM your playwright.config.ts and an existing Page Object as context, then describe the scenario you want to test.

The LLM-generated output tends to use the right locator strategies (when given examples), but assertions need review — generated tests often use too-specific assertions that break on minor UI changes, or miss important edge cases.

The most productive pattern: let the LLM generate the boilerplate structure, then manually review and adjust locators and assertions. This is faster than writing from scratch and faster than correcting all-LLM output without the structure benefit.

Debugging

Playwright’s debugging tools are good. In order of usefulness:

--debug flag: Opens Playwright Inspector, a UI for stepping through test actions with the browser visible. The fastest way to understand why a test is failing locally.

npx playwright test auth.spec.ts --debug

Traces: The trace file captured on retry contains a complete record of every action, screenshot, network request, and console log. Open in the Playwright Trace Viewer (npx playwright show-trace trace.zip) to step through exactly what happened.

page.pause(): Insert a breakpoint in test code. Suspends execution and opens the Playwright Inspector at that point. Useful for debugging a specific step without stepping through the whole test.

--headed: Run tests with the browser visible. Combined with --workers=1, useful for seeing what’s happening in a locally-failing test.

npx playwright test --headed --workers=1

What to Test with E2E

E2E tests are expensive relative to unit and integration tests. The right scope:

Test: Critical user journeys — sign up, sign in, primary feature workflows, checkout/conversion flows. These are the tests that catch regressions that matter to users.

Test: Integration points that can’t be tested at lower levels — third-party OAuth, payment flows, email verification.

Don’t test: Every edge case and validation message. Unit tests handle this more efficiently.

Don’t test: Internal implementation — database state, API responses in isolation. Integration tests are cheaper for this.

The target ratio for most applications: 70% unit, 20% integration, 10% E2E. The E2E layer should be small, focused on critical paths, and fast enough to run on every pull request. If your E2E suite takes 30 minutes, something has gone wrong with scope or parallelisation.