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:
- Attached to the DOM
- Visible
- Stable (not animating)
- 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
await page.waitForTimeout(1000);
await page.click('#btn');
// Auto-waits up to 30s
await page.getByRole('button').click();
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.