E2E: Cypress & Playwright

Unit and integration tests verify that your code works. End-to-End (E2E) tests verify that your application works for the user. They spin up a real browser, navigate to your URL, and click buttons just like a human.

1. The Tooling Landscape: Cypress vs. Playwright

While Selenium was the pioneer, modern web testing is dominated by two giants:

Feature Cypress Playwright (Recommended)
Architecture Runs inside the browser (injects JS) Runs outside via DevTools Protocol
Cross-Browser Chromium, Firefox, WebKit (experimental) Native support for all engines (including Safari)
Speed Slower (serial execution by default) Blazing fast (parallel by default)
Flakiness Automatic waiting built-in Auto-waiting + smart assertions
Debugging Time-travel debugger Trace Viewer (Step-by-step DOM snapshots)

[!NOTE] We recommend Playwright for new projects due to its speed, reliable Safari support, and powerful VS Code integration.

2. Writing Resilient Locators

The biggest challenge in E2E testing is flakiness: tests that pass sometimes and fail others. The #1 cause is fragile selectors (e.g., div > div:nth-child(3) > button).

Playwright’s philosophy mirrors React Testing Library: Test by user-visible attributes.

Anatomy of a Playwright Test

A typical test involves navigation, interaction, and assertion.

import { test, expect } from '@playwright/test';

test('user can log in', async ({ page }) => {
  // 1. Navigate
  await page.goto('https://myapp.com/login');

  // 2. Interact (Auto-waiting included!)
  await page.getByLabel('Email').fill('user@example.com');
  await page.getByLabel('Password').fill('secret');
  await page.getByRole('button', { name: 'Sign in' }).click();

  // 3. Assert
  // This waits for the URL to change automatically
  await expect(page).toHaveURL(/dashboard/);

  // This waits for the heading to appear
  await expect(page.getByRole('heading', { name: 'Welcome' })).toBeVisible();
});

3. Flakiness vs. Stability: The Power of Auto-Waiting

In older tools (Selenium), you often had to add sleep(1000) or explicit waits because the script ran faster than the browser.

Playwright automatically waits for elements to be:

  1. Attached to the DOM
  2. Visible
  3. Stable (not animating)
  4. Receiving events (not covered by other elements)

This drastically reduces flakiness without you writing a single wait statement.

Interactive: The Flakiness Simulator

See why manual waits fail. Try to run the tests against a button that loads unpredictably.

Browser View

Test Runner

1. The Flaky Way (Manual Wait)
await page.waitForTimeout(1000); await page.click('#btn');
Ready
2. The Playwright Way
// Auto-waits up to 30s await page.getByRole('button').click();
Ready

The “Bad” Way vs. The “Playwright” Way

// ❌ Flaky: Depends on arbitrary timing
await page.click('#submit');
await page.waitForTimeout(1000); // Magic number!
const text = await page.$eval('.alert', el => el.textContent);
expect(text).toBe('Success');

// ✅ Stable: Relies on events and state
await page.getByRole('button', { name: 'Submit' }).click();
// Automatically waits for the element to appear and have text
await expect(page.getByRole('alert')).toHaveText('Success');

4. Advanced Patterns

1. Global Setup (Authentication)

Logging in before every test is slow. Instead, log in once and save the storage state (cookies/localStorage).

playwright.config.ts:

export default defineConfig({
  projects: [
    { name: 'setup', testMatch: /.*\.setup\.ts/ },
    {
      name: 'chromium',
      use: { storageState: 'playwright/.auth/user.json' },
      dependencies: ['setup'],
    },
  ],
});

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: 'Sign in' }).click();
  await page.waitForURL('/dashboard');

  await page.context().storageState({ path: 'playwright/.auth/user.json' });
});

2. API Testing

Playwright isn’t just for UI. You can test your API endpoints directly, or use API calls to set up test data faster than the UI allows.

test('creates a user via API', async ({ request }) => {
  const newIssue = await request.post('/api/users', {
    data: {
      name: 'Jules',
      role: 'admin'
    }
  });
  expect(newIssue.ok()).toBeTruthy();
});

3. Visual Regression Testing

Sometimes HTML/CSS is correct, but the layout is broken visually (e.g., overlapping text). Playwright has built-in visual comparison.

test('landing page looks correct', async ({ page }) => {
  await page.goto('/');
  // Takes a screenshot and compares it to the "golden" baseline
  // saved in your repo. Fails if pixels differ.
  await expect(page).toHaveScreenshot();
});

[!TIP] Trace Viewer: When a test fails in CI, Playwright generates a “trace.zip”. You can upload this to trace.playwright.dev to see a full recording of the test, including DOM snapshots, network requests, and console logs for every step. It’s like time travel for debugging.