E2E Testing for React/Playwright
IMPORTANT: Use Playwright only. Do not use Cypress or other E2E frameworks.
Comprehensive guide for end-to-end testing in React applications using Playwright, including test generation, fixtures, and page object patterns.
Table of Contents
- •Quick Start
- •Critical Testing Principles
- •Test Infrastructure
- •Strict Assertion Patterns
- •Page Objects
- •Test Fixtures
- •Autonomous Exploration Mode
- •Anti-Patterns to Avoid
- •Reference Documentation
Quick Start
Prerequisites
E2E tests run against the real backend (no mocking).
Required:
- •Backend server running:
cd backend && npm run start:dev - •Frontend dev server running:
cd frontend && npm run dev - •Test users seeded in database
Run Tests
# Run all tests npm run test:e2e # Run specific test file npm run test:e2e -- my-ideas.spec.ts # Run with UI (watch mode) npm run test:e2e:ui # Run with browser visible npm run test:e2e:headed
Critical Testing Principles
The Core Problem
Tests that pass but real pages fail. This happens when tests:
- •Only check visibility, not functionality
- •Silently swallow errors
- •Don't verify navigation destinations
- •Skip tests via conditional logic
The Solution: Strict Browser-Based Testing
Every test must:
- •Test through the real UI (same path as users)
- •Fail explicitly when things don't work
- •Verify actual outcomes (not just "something appeared")
- •Test button clicks lead to expected destinations
Test Infrastructure
Directory Structure
frontend/e2e/ ├── tests/ # Test files by feature │ ├── auth/ │ │ ├── login.spec.ts │ │ ├── token-refresh.spec.ts │ │ └── timing-issues.spec.ts │ ├── dashboard/ │ │ ├── dashboard-home.spec.ts │ │ └── my-ideas.spec.ts │ ├── flows/ │ │ └── idea-lifecycle.spec.ts │ └── error-handling/ │ └── api-errors.spec.ts ├── page-objects/ # Page Object Models │ ├── base.page.ts │ └── dashboard/ ├── fixtures/ # Test fixtures │ ├── auth.fixture.ts │ └── base.fixture.ts ├── utils/ # Test utilities │ ├── strict-assertions.ts # CRITICAL: Use these │ ├── ui-auth-helpers.ts │ ├── token-testing.ts │ └── network-simulation.ts ├── global-setup.ts └── global-teardown.ts
Playwright Configuration
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e/tests',
// IMPORTANT: Disable parallel to avoid auth conflicts
fullyParallel: false,
workers: 1,
// Global setup verifies environment before tests
globalSetup: './e2e/global-setup.ts',
globalTeardown: './e2e/global-teardown.ts',
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'on-first-retry',
actionTimeout: 15000,
navigationTimeout: 30000,
},
// Start both backend and frontend
webServer: [
{
command: 'cd ../backend && npm run start:dev',
url: 'http://localhost:3000/api',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
{
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
],
});
Strict Assertion Patterns
Pattern 1: Content OR Empty State (NEVER both or neither)
// ❌ BAD - Silently passes if BOTH fail
await Promise.race([
content.waitFor().catch(() => {}),
emptyState.waitFor().catch(() => {}),
]);
// ✅ GOOD - Fails explicitly if neither appears
import { expectContentOrEmptyState } from '../utils/strict-assertions';
const result = await expectContentOrEmptyState(
ideaCards.first(),
emptyStateMessage,
15000
);
if (result === 'content') {
// Verify content has actual data
await expectContentLoaded(ideaCards.first(), { notEmpty: true });
} else {
// Verify empty state has proper messaging
await expectContentLoaded(emptyStateMessage, { notEmpty: true });
}
Pattern 2: Button Click with Destination Verification
// ❌ BAD - Only checks visibility
await expect(button).toBeVisible();
// ✅ GOOD - Verifies full interaction flow
await expect(button).toBeVisible();
await expect(button).toBeEnabled();
await button.click();
// Verify navigation
await expect(page).toHaveURL(/\/expected-path/);
// Verify destination page is functional
await page.waitForLoadState('networkidle');
const form = page.locator('form, [data-testid="expected-form"]');
await expect(form).toBeVisible();
Pattern 3: Form Save Verification
// ❌ BAD - Only checks form value
await input.fill('new value');
await expect(input).toHaveValue('new value');
// ✅ GOOD - Verifies data persisted
await input.fill('new value');
await submitButton.click();
// Wait for save to complete
await page.waitForLoadState('networkidle');
// Verify by reloading
await page.reload();
await expect(input).toHaveValue('new value'); // Persisted!
Pattern 4: No Conditional Test Skipping
// ❌ BAD - Silently passes if no ideas
if (hasIdeas) {
// test something
}
// ✅ GOOD - Fails if precondition not met
const result = await expectContentOrEmptyState(ideaCards, emptyState);
// If you expect ideas to exist, assert it
expect(result).toBe('content');
// Or handle both cases explicitly
if (result === 'content') {
// Test with ideas
} else {
// Test empty state behavior
}
Pattern 5: Stats Must Show Real Numbers
// ❌ BAD - Just checks visibility
await expect(statElement).toBeVisible();
// ✅ GOOD - Verifies actual data
import { expectStatLoaded } from '../utils/strict-assertions';
const value = await expectStatLoaded(statElement);
// value is a number, not '...' or 'Loading'
Page Objects
Base Page with Strict Helpers
// e2e/page-objects/base.page.ts
import { Page, Locator, expect } from '@playwright/test';
import { expectContentOrEmptyState } from '../utils/strict-assertions';
export abstract class BasePage {
constructor(protected readonly page: Page) {}
abstract readonly url: string;
async navigate(): Promise<void> {
await this.page.goto(this.url);
await this.page.waitForLoadState('networkidle');
}
// STRICT helper - fails if neither appears
protected async waitForContentOrEmpty(
contentLocator: Locator,
emptyStateLocator: Locator,
): Promise<'content' | 'empty'> {
return expectContentOrEmptyState(contentLocator, emptyStateLocator, 15000);
}
}
Example Page Object
// e2e/page-objects/dashboard/my-ideas.page.ts
import { Page, Locator, expect } from '@playwright/test';
import { BasePage } from '../base.page';
export class MyIdeasPage extends BasePage {
readonly url = '/dashboard/my-ideas';
readonly ideaCards: Locator;
readonly emptyState: Locator;
readonly continueEditButton: Locator;
readonly newProposalButton: Locator;
constructor(page: Page) {
super(page);
this.ideaCards = page.locator('[data-testid="idea-card"]');
this.emptyState = page.locator('text=No ideas yet');
this.continueEditButton = page.locator('button:has-text("Continue Edit")');
this.newProposalButton = page.locator('button:has-text("New Proposal")');
}
async waitForIdeas(): Promise<'content' | 'empty'> {
return this.waitForContentOrEmpty(this.ideaCards.first(), this.emptyState);
}
async clickContinueEdit(): Promise<void> {
const btn = this.continueEditButton.first();
await expect(btn).toBeVisible();
await expect(btn).toBeEnabled();
await btn.click();
}
}
Test Fixtures
UI-Based Authentication
// e2e/fixtures/auth.fixture.ts
import { test as baseTest, Page, expect } from '@playwright/test';
import { loginViaUI, verifyAuthCookies } from '../utils/ui-auth-helpers';
export const testUsers = {
user: {
email: 'testuser@test.com',
password: 'TestPassword123!',
},
admin: {
email: 'admin@test.com',
password: 'AdminPassword123!',
},
};
export const test = baseTest.extend({
// IMPORTANT: Login via UI, NOT cookie injection
loggedInPage: async ({ browser }, use) => {
const context = await browser.newContext();
const page = await context.newPage();
// Real UI login
await loginViaUI(page, testUsers.user);
// Verify cookies actually set
await verifyAuthCookies(context);
await use(page);
await context.close();
},
});
Complete Test Example
// e2e/tests/dashboard/my-ideas.spec.ts
import { test, expect } from '../../fixtures/auth.fixture';
import { MyIdeasPage } from '../../page-objects/dashboard/my-ideas.page';
import {
expectContentLoaded,
expectDataLoaded,
expectContentOrEmptyState,
} from '../../utils/strict-assertions';
test.describe('My Ideas - Continue Edit Flow', () => {
let myIdeasPage: MyIdeasPage;
test.beforeEach(async ({ loggedInPage }) => {
myIdeasPage = new MyIdeasPage(loggedInPage);
await myIdeasPage.navigate();
// STRICT: Wait for data to load, fail if loading persists
await expectDataLoaded(loggedInPage);
});
test('should navigate to edit page when clicking Continue Edit', async ({
loggedInPage: page,
}) => {
// Wait for content or empty state
const result = await myIdeasPage.waitForIdeas();
if (result === 'content') {
// Click Continue Edit
await myIdeasPage.clickContinueEdit();
// STRICT: Verify navigation to edit page
await expect(page).toHaveURL(/\/dashboard\/ideas\/.*\/edit/, {
timeout: 10000,
});
// STRICT: Verify edit form loads with data
await page.waitForLoadState('networkidle');
const titleInput = page.locator('input[name="projectName"]');
await expect(titleInput).toBeVisible();
// STRICT: Form must have pre-populated data
const value = await titleInput.inputValue();
expect(value.length).toBeGreaterThan(0);
}
});
});
Autonomous Exploration Mode
An automated testing mode where an agent freely navigates the application, interacts with ALL features, documents bugs, and generates fix suggestions.
When to Use
- •Discovery Testing: Explore a new codebase to find issues
- •Regression Hunting: Run after major changes to find broken flows
- •Smoke Testing: Quick validation that core paths work
- •Coverage Gaps: Find untested areas of the application
- •Fix Generation: Automatically generate fixes for common issues (404s, missing routes)
How It Works
- •Navigate: Agent visits pages using links, buttons, and known routes
- •Interact: Clicks ALL buttons, fills ALL forms, tests ALL functionality
- •Detect: Monitors for console errors, network failures, 404s, UI anomalies
- •Document: Records bugs with reproduction steps and screenshots
- •Generate Fixes: Creates fix suggestions for 404s, broken links, missing imports
Quick Start
// e2e/autonomous/exploration.spec.ts
import { test } from '../fixtures/auth.fixture';
import { ExplorationAgent } from './exploration-agent';
test('autonomous exploration with fix generation', async ({ loggedInPage }) => {
const agent = new ExplorationAgent(loggedInPage, {
maxDuration: 5 * 60 * 1000, // 5 minutes
maxActions: 100,
screenshotOnBug: true,
avoidDestructive: true,
outputDir: './exploration-reports',
// Enable comprehensive testing
testAllClickables: true, // Click EVERY clickable element
testForms: true, // Test search, filter, forms
testCRUD: false, // Create/delete with real data
// Enable fix generation
generateFixes: true,
fixOutputDir: './exploration-reports/auto-fixes',
});
const result = await agent.explore();
await result.writeReport();
console.log(`Bugs found: ${result.bugs.length}`);
console.log(`Fixes generated: ${result.fixes.length}`);
});
Run Commands
# Run autonomous exploration (all tests) npm run e2e:explore # Quick exploration (1 minute, 30 actions) npm run e2e:explore:quick # Public pages only (unauthenticated) npm run e2e:explore:public # Authenticated flows npm run e2e:explore:auth # With fix generation enabled npm run e2e:explore:fix # With CRUD testing (creates real data, then cleans up) npm run e2e:explore:crud # Run all exploration tests sequentially npm run e2e:explore:all # With browser visible npm run e2e:explore:headed
Configuration Options
| Option | Type | Default | Description |
|---|---|---|---|
maxDuration | number | 300000 | Max exploration time (ms) |
maxActions | number | 100 | Max interactions before stopping |
avoidDestructive | boolean | true | Skip delete/logout actions |
screenshotOnBug | boolean | true | Capture screenshot on bug |
focusAreas | string[] | [] | URL prefixes to prioritize |
excludeAreas | string[] | [] | URL prefixes to skip |
| Comprehensive Testing | |||
testAllClickables | boolean | true | Click every button, link, tab |
testForms | boolean | true | Test search, filter, form functionality |
testCRUD | boolean | false | Test create/delete with real data |
cleanupTestData | boolean | true | Delete test data after exploration |
| Fix Generation | |||
generateFixes | boolean | false | Generate fix suggestions for bugs |
fixOutputDir | string | auto-fixes | Directory for generated fixes |
fixTypes | FixType[] | ['missing-route', 'broken-link', 'missing-page'] | Types of fixes to generate |
Bug Detection
The agent detects:
- •Console Errors: Uncaught exceptions, TypeErrors, ReferenceErrors
- •Network Failures: 4xx/5xx API responses, timeouts
- •Broken Links: 404s when clicking links (with clicked element context)
- •Loading Issues: Stuck spinners, data never loads
- •Empty Content: Neither content nor empty state appears
- •Navigation Failures: Click doesn't lead to expected page
- •Form Errors: Validation failures, submission errors
Fix Generation
When generateFixes: true, the agent generates fix suggestions for:
| Bug Type | Fix Generated |
|---|---|
| 404 Navigation | Route definition + page component stub |
| Broken Link | Corrected href or missing page |
| Missing Import (ReferenceError) | Import statement suggestion |
Fixes are written to auto-fixes/ directory:
- •
fixes-summary.md- Human-readable summary with code snippets - •
fixes.json- Structured data for programmatic access - •
fix-001-*.tsx- Individual fix files
Bug Report Output
Reports are written to ./exploration-reports/ as markdown:
# Exploration Bug Report **Date**: 2024-01-15 **Duration**: 4m 32s **Pages Visited**: 12 **Bugs Found**: 3 **Fixes Generated**: 2 ## Bug #1: broken_link **Severity**: Major **URL**: /dashboard/transactions **HTTP Status**: 404 **Clicked Element**: Add Transaction **Target URL**: /transactions/new ### Reproduction Steps 1. Navigate to /dashboard 2. Click "Transactions" link 3. Click "Add Transaction" button ### Screenshot  ## Generated Fixes ### fix-001: Add missing route for /dashboard/transactions/new **Type**: missing-route **Priority**: high Files to modify: - ✏️ `app/routes/user.routes.ts` - ➕ `app/pages/dashboard/transactions/new.tsx` See `auto-fixes/fixes-summary.md` for full details.
Status Document Integration
The agent automatically updates .claude-project/status/frontend/E2E_QA_STATUS.md with:
- •Exploration session metrics (duration, pages, actions, bugs)
- •Bug summary by severity
- •Generated fixes list
- •All pages covered across test runs
Reference
See resources/autonomous-exploration.md for:
- •Complete implementation templates
- •Navigation algorithm details
- •Bug detection strategies
- •Form filling patterns
- •Fix generation logic
Anti-Patterns to Avoid
1. Silent Error Swallowing
// ❌ NEVER DO THIS
await Promise.race([
element.waitFor().catch(() => {}), // Silent failure!
]);
2. Cookie Injection Instead of UI Login
// ❌ BAD - Bypasses real auth flow
await context.addCookies([{ name: 'token', value: 'xyz' }]);
// ✅ GOOD - Tests real auth flow
await loginViaUI(page, credentials);
await verifyAuthCookies(context);
3. Visibility Without Functionality
// ❌ BAD - Button might be broken await expect(button).toBeVisible(); // ✅ GOOD - Tests actual functionality await expect(button).toBeVisible(); await expect(button).toBeEnabled(); await button.click(); await expect(page).toHaveURL(/expected-destination/);
4. Conditional Test Skipping
// ❌ BAD - Silently skips test
if (await element.isVisible()) {
// test code
}
// ✅ GOOD - Explicit assertion
await expect(element).toBeVisible();
// test code
5. Direct API Calls in Tests
// ❌ BAD - Bypasses frontend
const response = await fetch('/api/login', { ... });
// ✅ GOOD - Tests through UI
await page.goto('/login');
await page.fill('[name="email"]', email);
await page.click('button[type="submit"]');
Verification Strategy
After writing/fixing a test:
- •Run the test:
npm run test:e2e:headed -- <file> - •Break the feature: Disable the onClick handler
- •Verify test FAILS: Previously passing test should now fail
- •Restore the feature: Re-enable the handler
- •Verify test PASSES: Test should pass again
If a test passes when the feature is broken, the test is worthless.
Reference Documentation
Utility Files
- •
e2e/utils/strict-assertions.ts- CRITICAL: Use these helpers - •
e2e/utils/ui-auth-helpers.ts- UI-based login - •
e2e/utils/token-testing.ts- Token expiry simulation - •
e2e/utils/network-simulation.ts- Network condition mocking
Official Documentation
Line Count: ~470 lines (under 500 limit)