Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Comprehensive Playwright testing guide covering E2E, component, API, visual, accessibility, and security tests.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
frameworks/react.md
1# React Application Testing23## Table of Contents451. [Patterns](#patterns)62. [Setup](#setup)73. [Framework Tips](#framework-tips)84. [Anti-Patterns](#anti-patterns)95. [Related](#related)1011> **When to use**: Testing React apps built with Vite, Create React App, or custom bundlers. Covers E2E testing, component testing, React Router navigation, form libraries, portals, error boundaries, and context/state verification.12> **Prerequisites**: [configuration.md](../core/configuration.md), [locators.md](../core/locators.md)1314## Patterns1516### Testing Context and Global State1718**Use when**: Verifying React context (theme, auth, locale) and state management (Redux, Zustand) produce correct UI changes.19**Avoid when**: You want to assert on raw state objects—test the UI, not internal state.2021```typescript22import { test, expect } from '@playwright/test';2324test.describe('theme switching', () => {25test('toggle applies dark mode across pages', async ({ page }) => {26await page.goto('/preferences');2728const root = page.locator('html');29await expect(root).not.toHaveClass(/dark-mode/);3031await page.getByRole('switch', { name: 'Enable dark theme' }).click();32await expect(root).toHaveClass(/dark-mode/);3334await page.getByRole('link', { name: 'Dashboard' }).click();35await expect(page.locator('html')).toHaveClass(/dark-mode/);36});37});3839test.describe('cart state persistence', () => {40test('item count updates globally', async ({ page }) => {41await page.goto('/catalog');4243const badge = page.getByTestId('cart-badge');4445await page.getByRole('listitem')46.filter({ hasText: 'Wireless Headphones' })47.getByRole('button', { name: 'Add' })48.click();49await expect(badge).toHaveText('1');5051await page.getByRole('link', { name: 'Contact' }).click();52await expect(badge).toHaveText('1');53});54});5556test.describe('auth state', () => {57test('login updates header across components', async ({ page }) => {58await page.goto('/');5960await expect(page.getByRole('link', { name: 'Login' })).toBeVisible();6162await page.getByRole('link', { name: 'Login' }).click();63await page.getByLabel('Username').fill('testuser');64await page.getByLabel('Password').fill('secret123');65await page.getByRole('button', { name: 'Submit' }).click();6667await expect(page.getByRole('link', { name: 'Login' })).toBeHidden();68await expect(page.getByText('testuser')).toBeVisible();69});70});71```7273### React Router Navigation7475**Use when**: Testing client-side routing with React Router v6+—route transitions, URL parameters, protected routes, browser history.76**Avoid when**: Server-side routing (Next.js App Router—see [nextjs.md](nextjs.md)).7778```typescript79import { test, expect } from '@playwright/test';8081test.describe('client routing', () => {82test('navigation preserves SPA state', async ({ page }) => {83await page.goto('/');8485await page.evaluate(() => {86(window as any).__spaMarker = 'active';87});8889await page.getByRole('link', { name: 'Inventory' }).click();90await page.waitForURL('/inventory');9192const marker = await page.evaluate(() => (window as any).__spaMarker);93expect(marker).toBe('active');94});9596test('query params filter content', async ({ page }) => {97await page.goto('/items?type=books');9899await expect(page.getByRole('heading', { name: 'Books' })).toBeVisible();100101await page.getByRole('link', { name: 'Music' }).click();102await page.waitForURL('/items?type=music');103104await expect(page.getByRole('heading', { name: 'Music' })).toBeVisible();105});106107test('nested routes render layouts', async ({ page }) => {108await page.goto('/account/security');109110await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();111await expect(page.getByRole('heading', { name: 'Security', level: 2 })).toBeVisible();112113await page.getByRole('link', { name: 'Privacy' }).click();114await page.waitForURL('/account/privacy');115116await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();117await expect(page.getByRole('heading', { name: 'Privacy', level: 2 })).toBeVisible();118});119120test('history navigation works', async ({ page }) => {121await page.goto('/');122await page.getByRole('link', { name: 'Inventory' }).click();123await page.waitForURL('/inventory');124await page.getByRole('link', { name: 'Help' }).click();125await page.waitForURL('/help');126127await page.goBack();128await expect(page).toHaveURL(/\/inventory/);129130await page.goBack();131await expect(page).toHaveURL(/\/$/);132});133134test('protected route redirects', async ({ page }) => {135await page.goto('/admin/users');136137await expect(page).toHaveURL(/\/login/);138});139140test('unknown route shows 404', async ({ page }) => {141await page.goto('/nonexistent-path');142143await expect(page.getByRole('heading', { name: 'Not Found' })).toBeVisible();144});145});146```147148### Testing Hooks Through UI149150**Use when**: Verifying custom hooks produce correct UI behavior—Playwright cannot call hooks directly.151**Avoid when**: Hook logic is pure computation—use unit tests instead.152153```typescript154import { test, expect } from '@playwright/test';155156test.describe('useDebounce via SearchBox', () => {157test('batches rapid input', async ({ page }) => {158await page.goto('/search');159160const apiCalls: string[] = [];161await page.route('**/api/query*', async (route) => {162apiCalls.push(route.request().url());163await route.continue();164});165166await page.getByRole('textbox', { name: 'Search' }).pressSequentially('testing', {167delay: 40,168});169170await expect(page.getByRole('listitem')).toHaveCount(3);171expect(apiCalls.length).toBeLessThanOrEqual(2);172});173});174175test.describe('usePagination via DataGrid', () => {176test('page controls work', async ({ page }) => {177await page.goto('/records');178179await expect(page.getByText('Page 1 of 10')).toBeVisible();180181await page.getByRole('button', { name: 'Next' }).click();182await expect(page.getByText('Page 2 of 10')).toBeVisible();183184await page.getByRole('button', { name: 'Previous' }).click();185await expect(page.getByText('Page 1 of 10')).toBeVisible();186await expect(page.getByRole('button', { name: 'Previous' })).toBeDisabled();187});188});189```190191### Form Libraries (React Hook Form, Formik)192193**Use when**: Testing forms built with react-hook-form or Formik—Playwright interacts with DOM, form library is transparent.194195```typescript196import { test, expect } from '@playwright/test';197198test.describe('signup form', () => {199test.beforeEach(async ({ page }) => {200await page.goto('/signup');201});202203test('validation on empty submit', async ({ page }) => {204await page.getByRole('button', { name: 'Register' }).click();205206await expect(page.getByText('Email required')).toBeVisible();207await expect(page.getByText('Password required')).toBeVisible();208});209210test('inline validation on blur', async ({ page }) => {211const email = page.getByLabel('Email');212await email.fill('invalid');213await email.blur();214215await expect(page.getByText('Invalid email format')).toBeVisible();216});217218test('password strength indicator', async ({ page }) => {219const pwd = page.getByLabel('Password', { exact: true });220221await pwd.fill('weak');222await expect(page.getByText('Minimum 8 characters')).toHaveClass(/invalid/);223224await pwd.fill('StrongPass1!');225await expect(page.getByText('Minimum 8 characters')).toHaveClass(/valid/);226});227228test('successful submission redirects', async ({ page }) => {229await page.getByLabel('Name').fill('Alice');230await page.getByLabel('Email').fill('[email protected]');231await page.getByLabel('Password', { exact: true }).fill('Secure123!');232await page.getByLabel('Confirm').fill('Secure123!');233await page.getByLabel('Accept terms').check();234235await page.getByRole('button', { name: 'Register' }).click();236237await page.waitForURL('/welcome');238await expect(page.getByText('Hello, Alice')).toBeVisible();239});240241test('submit button disabled during request', async ({ page }) => {242await page.route('**/api/signup', async (route) => {243await new Promise((r) => setTimeout(r, 800));244await route.fulfill({ status: 201, json: { id: 1 } });245});246247await page.getByLabel('Name').fill('Bob');248await page.getByLabel('Email').fill('[email protected]');249await page.getByLabel('Password', { exact: true }).fill('Secure123!');250await page.getByLabel('Confirm').fill('Secure123!');251await page.getByLabel('Accept terms').check();252253await page.getByRole('button', { name: 'Register' }).click();254255await expect(page.getByRole('button', { name: /Registering|Loading/ })).toBeDisabled();256});257});258```259260### Portals (Modals, Tooltips, Dropdowns)261262**Use when**: Testing components rendered via `ReactDOM.createPortal()`—modals, dialogs, tooltips, menus. These render outside parent DOM but Playwright sees the full document.263264```typescript265import { test, expect } from '@playwright/test';266267test.describe('portal components', () => {268test('modal interaction', async ({ page }) => {269await page.goto('/items');270271await page.getByRole('button', { name: 'Remove' }).first().click();272273const dialog = page.getByRole('dialog', { name: 'Confirm removal' });274await expect(dialog).toBeVisible();275await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused();276277await dialog.getByRole('button', { name: 'Remove' }).click();278await expect(dialog).toBeHidden();279});280281test('escape closes modal', async ({ page }) => {282await page.goto('/items');283await page.getByRole('button', { name: 'Remove' }).first().click();284285const dialog = page.getByRole('dialog');286await expect(dialog).toBeVisible();287288await page.keyboard.press('Escape');289await expect(dialog).toBeHidden();290});291292test('tooltip on hover', async ({ page }) => {293await page.goto('/panel');294295await page.getByRole('button', { name: 'Help' }).hover();296await expect(page.getByRole('tooltip')).toBeVisible();297298await page.mouse.move(0, 0);299await expect(page.getByRole('tooltip')).toBeHidden();300});301302test('dropdown menu', async ({ page }) => {303await page.goto('/panel');304305await page.getByRole('button', { name: 'Actions' }).click();306307const menu = page.getByRole('menu');308await expect(menu).toBeVisible();309310await menu.getByRole('menuitem', { name: 'Rename' }).click();311await expect(menu).toBeHidden();312});313314test('toast auto-dismisses', async ({ page }) => {315await page.goto('/preferences');316317await page.getByRole('button', { name: 'Save' }).click();318await expect(page.getByText('Preferences saved')).toBeVisible();319320await expect(page.getByText('Preferences saved')).toBeHidden({ timeout: 8000 });321});322});323```324325### Error Boundaries326327**Use when**: Verifying error boundaries catch rendering errors and show fallback UI.328**Avoid when**: Testing error handling in event handlers or async code—error boundaries only catch render errors.329330```typescript331import { test, expect } from '@playwright/test';332333test.describe('error boundary', () => {334test('shows fallback on crash', async ({ page }) => {335await page.route('**/api/widgets', (route) => {336route.fulfill({337status: 200,338json: { widgets: null },339});340});341342await page.goto('/panel');343344await expect(page.getByText('Something went wrong')).toBeVisible();345await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();346await expect(page.getByRole('navigation')).toBeVisible();347});348349test('retry recovers component', async ({ page }) => {350let calls = 0;351await page.route('**/api/widgets', (route) => {352calls++;353if (calls === 1) {354route.fulfill({ status: 200, json: { widgets: null } });355} else {356route.fulfill({ status: 200, json: { widgets: [{ id: 1, name: 'Chart' }] } });357}358});359360await page.goto('/panel');361362await expect(page.getByText('Something went wrong')).toBeVisible();363364await page.getByRole('button', { name: 'Retry' }).click();365366await expect(page.getByText('Something went wrong')).toBeHidden();367await expect(page.getByText('Chart')).toBeVisible();368});369});370```371372### Component Testing (Experimental)373374**Use when**: Testing complex interactive components in isolation—data tables, form wizards, rich editors. Needs real browser but not full app.375**Avoid when**: Component depends heavily on backend data or routing—use E2E instead.376377```typescript378// playwright-ct.config.ts379import { defineConfig, devices } from '@playwright/experimental-ct-react';380381export default defineConfig({382testDir: './tests/components',383testMatch: '**/*.ct.ts',384use: {385trace: 'on-first-retry',386ctPort: 3100,387},388projects: [389{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },390],391});392```393394```typescript395// tests/components/Stepper.ct.ts396import { test, expect } from '@playwright/experimental-ct-react';397import Stepper from '../../src/components/Stepper';398399test('increments on click', async ({ mount }) => {400const component = await mount(<Stepper initial={0} />);401402await expect(component.getByText('Value: 0')).toBeVisible();403await component.getByRole('button', { name: '+' }).click();404await expect(component.getByText('Value: 1')).toBeVisible();405});406407test('fires onChange callback', async ({ mount }) => {408const values: number[] = [];409const component = await mount(410<Stepper initial={0} onChange={(v) => values.push(v)} />411);412413await component.getByRole('button', { name: '+' }).click();414await component.getByRole('button', { name: '+' }).click();415416expect(values).toEqual([1, 2]);417});418419test('respects min boundary', async ({ mount }) => {420const component = await mount(<Stepper initial={0} min={0} />);421422await expect(component.getByRole('button', { name: '-' })).toBeDisabled();423});424```425426## Setup427428### E2E Config (Vite)429430```typescript431// playwright.config.ts432import { defineConfig, devices } from '@playwright/test';433434export default defineConfig({435testDir: './tests',436fullyParallel: true,437forbidOnly: !!process.env.CI,438retries: process.env.CI ? 2 : 0,439workers: process.env.CI ? '50%' : undefined,440441use: {442baseURL: 'http://localhost:5173',443trace: 'on-first-retry',444screenshot: 'only-on-failure',445},446447projects: [448{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },449{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },450{ name: 'mobile', use: { ...devices['iPhone 14'] } },451],452453webServer: {454command: process.env.CI ? 'npm run build && npx vite preview --port 5173' : 'npm run dev',455url: 'http://localhost:5173',456reuseExistingServer: !process.env.CI,457timeout: 120_000,458},459});460```461462### CRA vs Vite Differences463464| Aspect | Create React App | Vite |465|---|---|---|466| Default port | `3000` | `5173` |467| Build output | `build/` | `dist/` |468| Serve production | `npx serve -s build -l 3000` | `npx vite preview --port 5173` |469| Env var prefix | `REACT_APP_*` | `VITE_*` |470471## Framework Tips472473### Strict Mode Double Effects474475React Strict Mode runs effects twice in development. Tests should be resilient:476477- Don't assert exact API call counts in dev mode478- Run against production build for call count assertions, or account for double invocations479480### Suspense and Lazy Components481482```typescript483test('lazy route loads content', async ({ page }) => {484await page.goto('/');485486await page.getByRole('link', { name: 'Analytics' }).click();487488await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible();489});490```491492### Detecting Memory Leaks493494```typescript495test('no unmounted state warnings', async ({ page }) => {496const warnings: string[] = [];497page.on('console', (msg) => {498if (msg.type() === 'warning' && msg.text().includes('unmounted')) {499warnings.push(msg.text());500}501});502503await page.goto('/panel');504await page.getByRole('link', { name: 'Settings' }).click();505await page.goBack();506await page.getByRole('link', { name: 'Profile' }).click();507508expect(warnings).toEqual([]);509});510```511512## Anti-Patterns513514| Don't | Problem | Do Instead |515|---|---|---|516| `page.evaluate(() => store.getState())` | Couples tests to implementation | Assert on UI: `expect(badge).toHaveText('3')` |517| Import components in E2E tests | E2E runs in Node, not browser | Use `@playwright/experimental-ct-react` for components |518| `page.waitForTimeout(500)` after state changes | Timing varies across machines | `expect(locator).toHaveText('value')` auto-retries |519| `page.locator('.MuiButton-root')` | Class names change between versions | `page.getByRole('button', { name: 'Submit' })` |520| Test every component with CT | Overhead for simple components | CT for complex widgets, unit tests for logic, E2E for flows |521| Skip keyboard navigation tests | Accessibility regressions common | Test Tab, Enter, Escape, Arrow interactions |522| Assert on `__REACT_FIBER__` internals | Not stable across versions | Only interact with rendered DOM |523524## Related525526- [locators.md](../core/locators.md) — locator strategies for any React component library527- [assertions-waiting.md](../core/assertions-waiting.md) — auto-waiting for React state changes528- [forms-validation.md](../testing-patterns/forms-validation.md) — form testing patterns529- [component-testing.md](../testing-patterns/component-testing.md) — in-depth component testing530- [test-architecture.md](../architecture/test-architecture.md) — E2E vs component vs unit decisions531- [nextjs.md](nextjs.md) — Next.js-specific patterns for SSR532