chore: add playwright cursor skill

This commit is contained in:
Anish Sarkar 2026-05-10 04:19:55 +05:30
parent 25aad38ca4
commit d52225c18d
57 changed files with 25244 additions and 0 deletions

View file

@ -0,0 +1,469 @@
# Next.js Testing Patterns
## Table of Contents
1. [Setup](#setup)
2. [App Router Patterns](#app-router-patterns)
3. [Pages Router Patterns](#pages-router-patterns)
4. [Dynamic Routes](#dynamic-routes)
5. [API Routes](#api-routes)
6. [Middleware Testing](#middleware-testing)
7. [Hydration Testing](#hydration-testing)
8. [next/image Testing](#nextimage-testing)
9. [NextAuth.js Authentication](#nextauthjs-authentication)
10. [Tips](#tips)
11. [Anti-Patterns](#anti-patterns)
12. [Related](#related)
> **When to use**: Testing Next.js applications with App Router, Pages Router, API routes, middleware, SSR, dynamic routes, and server components.
> **Prerequisites**: [configuration.md](../core/configuration.md), [locators.md](../core/locators.md)
## Setup
### Configuration with webServer
```typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? '50%' : undefined,
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
],
webServer: {
command: process.env.CI
? 'npm run build && npm run start'
: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
env: {
NODE_ENV: process.env.CI ? 'production' : 'test',
},
},
});
```
### Environment Variables
Next.js loads `.env.test` when `NODE_ENV=test`:
```bash
# .env.test (commit this)
NEXT_PUBLIC_API_URL=http://localhost:3000/api
DATABASE_URL=postgresql://localhost:5432/test_db
# .env.test.local (gitignored)
NEXTAUTH_SECRET=test-secret-local
```
## App Router Patterns
### Server Component Content
```typescript
test('renders server component content', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Welcome', level: 1 })).toBeVisible();
await expect(page.getByRole('navigation', { name: 'Main' })).toBeVisible();
});
```
### Loading States with Streaming
```typescript
test('loading state during data streaming', async ({ page }) => {
await page.route('**/api/stats', async (route) => {
await new Promise((r) => setTimeout(r, 2000));
await route.continue();
});
await page.goto('/dashboard');
await expect(page.getByRole('progressbar')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByRole('progressbar')).toBeHidden();
});
```
### Nested Layouts
```typescript
test('layouts persist across navigation', async ({ page }) => {
await page.goto('/dashboard/analytics');
const sidebar = page.getByRole('navigation', { name: 'Dashboard' });
await expect(sidebar).toBeVisible();
await sidebar.getByRole('link', { name: 'Settings' }).click();
await page.waitForURL('/dashboard/settings');
await expect(sidebar).toBeVisible();
await expect(page.getByRole('heading', { name: 'Settings' })).toBeVisible();
});
```
## Pages Router Patterns
### SSR with getServerSideProps
```typescript
test('page with getServerSideProps renders data', async ({ page }) => {
await page.goto('/blog');
await expect(page.getByRole('heading', { name: 'Blog', level: 1 })).toBeVisible();
await expect(page.getByRole('article')).toHaveCount(10);
await expect(page.getByRole('article').first()).toContainText(/\w+/);
});
```
### Static Generation with getStaticProps
```typescript
test('static page shows pre-rendered content', async ({ page }) => {
await page.goto('/about');
await expect(page.getByRole('heading', { name: 'About Us' })).toBeVisible();
await expect(page.getByText('Founded in 2020')).toBeVisible();
});
```
## Dynamic Routes
### Slug Parameters
```typescript
test('dynamic [slug] renders correct content', async ({ page }) => {
await page.goto('/blog/testing-guide');
await expect(page.getByRole('heading', { level: 1 })).toContainText('Testing Guide');
await expect(page.getByText('Page not found')).toBeHidden();
});
test('non-existent slug shows 404', async ({ page }) => {
const response = await page.goto('/blog/nonexistent-post');
expect(response?.status()).toBe(404);
await expect(page.getByRole('heading', { name: '404' })).toBeVisible();
});
```
### Catch-All Routes
```typescript
test('catch-all handles nested paths', async ({ page }) => {
await page.goto('/docs/getting-started/installation');
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
await page.goto('/docs/api/configuration');
await expect(page.getByRole('heading', { name: 'Configuration' })).toBeVisible();
});
```
### Query Parameters
```typescript
test('query parameters filter content', async ({ page }) => {
await page.goto('/products?category=electronics&sort=price-asc');
await expect(page.getByRole('heading', { name: 'Electronics' })).toBeVisible();
const prices = await page.getByTestId('product-price').allTextContents();
const numericPrices = prices.map((p) => parseFloat(p.replace('$', '')));
expect(numericPrices).toEqual([...numericPrices].sort((a, b) => a - b));
});
```
## API Routes
### Direct API Testing
```typescript
test('GET /api/products returns list', async ({ request }) => {
const response = await request.get('/api/products');
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.products).toBeInstanceOf(Array);
expect(body.products[0]).toHaveProperty('id');
expect(body.products[0]).toHaveProperty('name');
});
test('POST /api/products creates item', async ({ request }) => {
const response = await request.post('/api/products', {
data: { name: 'Test Product', price: 29.99 },
});
expect(response.status()).toBe(201);
const body = await response.json();
expect(body.product.name).toBe('Test Product');
});
test('POST /api/products validates fields', async ({ request }) => {
const response = await request.post('/api/products', {
data: { name: '' },
});
expect(response.status()).toBe(400);
const body = await response.json();
expect(body.error).toContainEqual(expect.objectContaining({ field: 'price' }));
});
```
### API Through UI
```typescript
test('form submission calls API', async ({ page }) => {
await page.goto('/products/new');
await page.getByLabel('Product name').fill('Widget');
await page.getByLabel('Price').fill('19.99');
await page.getByRole('button', { name: 'Create product' }).click();
await expect(page.getByText('Product created successfully')).toBeVisible();
await page.waitForURL('/products/**');
});
```
## Middleware Testing
### Auth Redirects
```typescript
test('unauthenticated user redirected to login', async ({ page }) => {
await page.goto('/dashboard');
expect(page.url()).toContain('/login');
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
});
test('redirect preserves return URL', async ({ page }) => {
await page.goto('/dashboard/settings');
const url = new URL(page.url());
expect(url.pathname).toBe('/login');
expect(url.searchParams.get('callbackUrl') || url.searchParams.get('returnTo'))
.toContain('/dashboard/settings');
});
```
### Security Headers
```typescript
test('middleware sets security headers', async ({ page }) => {
const response = await page.goto('/');
const headers = response!.headers();
expect(headers['x-frame-options']).toBe('DENY');
expect(headers['x-content-type-options']).toBe('nosniff');
});
```
### Locale Rewrites
```typescript
test('middleware rewrites based on locale', async ({ page, context }) => {
await context.setExtraHTTPHeaders({
'Accept-Language': 'fr-FR,fr;q=0.9',
});
await page.goto('/');
await expect(page.getByText('Bienvenue')).toBeVisible();
});
```
## Hydration Testing
### Console Error Detection
```typescript
test('no hydration errors in console', async ({ page }) => {
const consoleErrors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
consoleErrors.push(msg.text());
}
});
await page.goto('/');
await page.getByRole('button', { name: 'Get started' }).click();
const hydrationErrors = consoleErrors.filter(
(e) =>
e.includes('Hydration') ||
e.includes('hydration') ||
e.includes('did not match')
);
expect(hydrationErrors).toEqual([]);
});
```
### Interactive Elements After Hydration
```typescript
test('interactive elements work after hydration', async ({ page }) => {
await page.goto('/');
const counter = page.getByTestId('counter-value');
await expect(counter).toHaveText('0');
await page.getByRole('button', { name: 'Increment' }).click();
await expect(counter).toHaveText('1');
});
```
## next/image Testing
```typescript
test('hero image loads with srcset', async ({ page }) => {
await page.goto('/');
const heroImage = page.getByRole('img', { name: 'Hero banner' });
await expect(heroImage).toBeVisible();
const srcset = await heroImage.getAttribute('srcset');
expect(srcset).toBeTruthy();
expect(srcset).toContain('w=');
const loading = await heroImage.getAttribute('loading');
expect(loading).not.toBe('lazy');
});
test('offscreen images lazy load', async ({ page }) => {
await page.goto('/gallery');
const offscreenImage = page.getByRole('img', { name: 'Gallery item 20' });
await offscreenImage.scrollIntoViewIfNeeded();
await expect(offscreenImage).toBeVisible();
const naturalWidth = await offscreenImage.evaluate(
(img: HTMLImageElement) => img.naturalWidth
);
expect(naturalWidth).toBeGreaterThan(0);
});
```
## NextAuth.js Authentication
### Setup Project
```typescript
// playwright.config.ts
export default defineConfig({
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'authenticated',
use: { storageState: 'playwright/.auth/user.json' },
dependencies: ['setup'],
},
{ name: 'unauthenticated', testMatch: '**/*.unauth.spec.ts' },
],
});
```
### Auth Setup
```typescript
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
const authFile = 'playwright/.auth/user.json';
setup('authenticate via credentials', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Email').fill('test@example.com');
await page.getByLabel('Password').fill(process.env.TEST_PASSWORD!);
await page.getByRole('button', { name: 'Sign in' }).click();
await page.waitForURL('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.context().storageState({ path: authFile });
});
```
### Authenticated Tests
```typescript
test('authenticated user sees dashboard', async ({ page }) => {
await page.goto('/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await expect(page.getByText('test@example.com')).toBeVisible();
});
```
## Tips
### Dev Server vs Production Build
| Scenario | Command | Trade-off |
|---|---|---|
| Local development | `npm run dev` | Fast iteration, no production behavior |
| CI pipeline | `npm run build && npm run start` | Tests real production bundle |
### Turbopack
```typescript
webServer: {
command: process.env.CI
? 'npm run build && npm run start'
: 'npx next dev --turbopack',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
```
### Multiple webServer Entries
```typescript
webServer: [
{
command: 'npm run dev:api',
url: 'http://localhost:4000/health',
reuseExistingServer: !process.env.CI,
},
{
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
],
```
## Anti-Patterns
| Don't Do This | Problem | Do This Instead |
|---|---|---|
| `await page.waitForTimeout(3000)` | Arbitrary waits are fragile | `await page.waitForURL('/path')` or `await expect(locator).toBeVisible()` |
| Test `getServerSideProps` directly | Depends on req/res context | Navigate to page and verify rendered output |
| Mock your own API routes | Hides real API bugs | Let real API handle requests; mock only external services |
| `page.goto('http://localhost:3000/path')` | Breaks when port changes | Use `page.goto('/path')` with `baseURL` |
| Run `npm run build` locally for every test | Extremely slow | Use `npm run dev` locally with `reuseExistingServer: true` |
| Test `next/image` by checking exact URLs | Paths change between dev/prod | Assert on `alt`, visibility, `naturalWidth > 0`, `srcset` |
| Test server actions by calling as functions | Server actions need Next.js runtime | Trigger through UI (forms, buttons) |
## Related
- [configuration.md](../core/configuration.md) -- Playwright configuration including `webServer`
- [authentication.md](../advanced/authentication.md) -- authentication setup and `storageState`
- [api-testing.md](../testing-patterns/api-testing.md) -- testing API routes with `request` context
- [react.md](react.md) -- React patterns for Next.js client components

View file

@ -0,0 +1,531 @@
# React Application Testing
## Table of Contents
1. [Patterns](#patterns)
2. [Setup](#setup)
3. [Framework Tips](#framework-tips)
4. [Anti-Patterns](#anti-patterns)
5. [Related](#related)
> **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.
> **Prerequisites**: [configuration.md](../core/configuration.md), [locators.md](../core/locators.md)
## Patterns
### Testing Context and Global State
**Use when**: Verifying React context (theme, auth, locale) and state management (Redux, Zustand) produce correct UI changes.
**Avoid when**: You want to assert on raw state objects—test the UI, not internal state.
```typescript
import { test, expect } from '@playwright/test';
test.describe('theme switching', () => {
test('toggle applies dark mode across pages', async ({ page }) => {
await page.goto('/preferences');
const root = page.locator('html');
await expect(root).not.toHaveClass(/dark-mode/);
await page.getByRole('switch', { name: 'Enable dark theme' }).click();
await expect(root).toHaveClass(/dark-mode/);
await page.getByRole('link', { name: 'Dashboard' }).click();
await expect(page.locator('html')).toHaveClass(/dark-mode/);
});
});
test.describe('cart state persistence', () => {
test('item count updates globally', async ({ page }) => {
await page.goto('/catalog');
const badge = page.getByTestId('cart-badge');
await page.getByRole('listitem')
.filter({ hasText: 'Wireless Headphones' })
.getByRole('button', { name: 'Add' })
.click();
await expect(badge).toHaveText('1');
await page.getByRole('link', { name: 'Contact' }).click();
await expect(badge).toHaveText('1');
});
});
test.describe('auth state', () => {
test('login updates header across components', async ({ page }) => {
await page.goto('/');
await expect(page.getByRole('link', { name: 'Login' })).toBeVisible();
await page.getByRole('link', { name: 'Login' }).click();
await page.getByLabel('Username').fill('testuser');
await page.getByLabel('Password').fill('secret123');
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page.getByRole('link', { name: 'Login' })).toBeHidden();
await expect(page.getByText('testuser')).toBeVisible();
});
});
```
### React Router Navigation
**Use when**: Testing client-side routing with React Router v6+—route transitions, URL parameters, protected routes, browser history.
**Avoid when**: Server-side routing (Next.js App Router—see [nextjs.md](nextjs.md)).
```typescript
import { test, expect } from '@playwright/test';
test.describe('client routing', () => {
test('navigation preserves SPA state', async ({ page }) => {
await page.goto('/');
await page.evaluate(() => {
(window as any).__spaMarker = 'active';
});
await page.getByRole('link', { name: 'Inventory' }).click();
await page.waitForURL('/inventory');
const marker = await page.evaluate(() => (window as any).__spaMarker);
expect(marker).toBe('active');
});
test('query params filter content', async ({ page }) => {
await page.goto('/items?type=books');
await expect(page.getByRole('heading', { name: 'Books' })).toBeVisible();
await page.getByRole('link', { name: 'Music' }).click();
await page.waitForURL('/items?type=music');
await expect(page.getByRole('heading', { name: 'Music' })).toBeVisible();
});
test('nested routes render layouts', async ({ page }) => {
await page.goto('/account/security');
await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Security', level: 2 })).toBeVisible();
await page.getByRole('link', { name: 'Privacy' }).click();
await page.waitForURL('/account/privacy');
await expect(page.getByRole('heading', { name: 'Account' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Privacy', level: 2 })).toBeVisible();
});
test('history navigation works', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Inventory' }).click();
await page.waitForURL('/inventory');
await page.getByRole('link', { name: 'Help' }).click();
await page.waitForURL('/help');
await page.goBack();
await expect(page).toHaveURL(/\/inventory/);
await page.goBack();
await expect(page).toHaveURL(/\/$/);
});
test('protected route redirects', async ({ page }) => {
await page.goto('/admin/users');
await expect(page).toHaveURL(/\/login/);
});
test('unknown route shows 404', async ({ page }) => {
await page.goto('/nonexistent-path');
await expect(page.getByRole('heading', { name: 'Not Found' })).toBeVisible();
});
});
```
### Testing Hooks Through UI
**Use when**: Verifying custom hooks produce correct UI behavior—Playwright cannot call hooks directly.
**Avoid when**: Hook logic is pure computation—use unit tests instead.
```typescript
import { test, expect } from '@playwright/test';
test.describe('useDebounce via SearchBox', () => {
test('batches rapid input', async ({ page }) => {
await page.goto('/search');
const apiCalls: string[] = [];
await page.route('**/api/query*', async (route) => {
apiCalls.push(route.request().url());
await route.continue();
});
await page.getByRole('textbox', { name: 'Search' }).pressSequentially('testing', {
delay: 40,
});
await expect(page.getByRole('listitem')).toHaveCount(3);
expect(apiCalls.length).toBeLessThanOrEqual(2);
});
});
test.describe('usePagination via DataGrid', () => {
test('page controls work', async ({ page }) => {
await page.goto('/records');
await expect(page.getByText('Page 1 of 10')).toBeVisible();
await page.getByRole('button', { name: 'Next' }).click();
await expect(page.getByText('Page 2 of 10')).toBeVisible();
await page.getByRole('button', { name: 'Previous' }).click();
await expect(page.getByText('Page 1 of 10')).toBeVisible();
await expect(page.getByRole('button', { name: 'Previous' })).toBeDisabled();
});
});
```
### Form Libraries (React Hook Form, Formik)
**Use when**: Testing forms built with react-hook-form or Formik—Playwright interacts with DOM, form library is transparent.
```typescript
import { test, expect } from '@playwright/test';
test.describe('signup form', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/signup');
});
test('validation on empty submit', async ({ page }) => {
await page.getByRole('button', { name: 'Register' }).click();
await expect(page.getByText('Email required')).toBeVisible();
await expect(page.getByText('Password required')).toBeVisible();
});
test('inline validation on blur', async ({ page }) => {
const email = page.getByLabel('Email');
await email.fill('invalid');
await email.blur();
await expect(page.getByText('Invalid email format')).toBeVisible();
});
test('password strength indicator', async ({ page }) => {
const pwd = page.getByLabel('Password', { exact: true });
await pwd.fill('weak');
await expect(page.getByText('Minimum 8 characters')).toHaveClass(/invalid/);
await pwd.fill('StrongPass1!');
await expect(page.getByText('Minimum 8 characters')).toHaveClass(/valid/);
});
test('successful submission redirects', async ({ page }) => {
await page.getByLabel('Name').fill('Alice');
await page.getByLabel('Email').fill('alice@test.com');
await page.getByLabel('Password', { exact: true }).fill('Secure123!');
await page.getByLabel('Confirm').fill('Secure123!');
await page.getByLabel('Accept terms').check();
await page.getByRole('button', { name: 'Register' }).click();
await page.waitForURL('/welcome');
await expect(page.getByText('Hello, Alice')).toBeVisible();
});
test('submit button disabled during request', async ({ page }) => {
await page.route('**/api/signup', async (route) => {
await new Promise((r) => setTimeout(r, 800));
await route.fulfill({ status: 201, json: { id: 1 } });
});
await page.getByLabel('Name').fill('Bob');
await page.getByLabel('Email').fill('bob@test.com');
await page.getByLabel('Password', { exact: true }).fill('Secure123!');
await page.getByLabel('Confirm').fill('Secure123!');
await page.getByLabel('Accept terms').check();
await page.getByRole('button', { name: 'Register' }).click();
await expect(page.getByRole('button', { name: /Registering|Loading/ })).toBeDisabled();
});
});
```
### Portals (Modals, Tooltips, Dropdowns)
**Use when**: Testing components rendered via `ReactDOM.createPortal()`—modals, dialogs, tooltips, menus. These render outside parent DOM but Playwright sees the full document.
```typescript
import { test, expect } from '@playwright/test';
test.describe('portal components', () => {
test('modal interaction', async ({ page }) => {
await page.goto('/items');
await page.getByRole('button', { name: 'Remove' }).first().click();
const dialog = page.getByRole('dialog', { name: 'Confirm removal' });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused();
await dialog.getByRole('button', { name: 'Remove' }).click();
await expect(dialog).toBeHidden();
});
test('escape closes modal', async ({ page }) => {
await page.goto('/items');
await page.getByRole('button', { name: 'Remove' }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await page.keyboard.press('Escape');
await expect(dialog).toBeHidden();
});
test('tooltip on hover', async ({ page }) => {
await page.goto('/panel');
await page.getByRole('button', { name: 'Help' }).hover();
await expect(page.getByRole('tooltip')).toBeVisible();
await page.mouse.move(0, 0);
await expect(page.getByRole('tooltip')).toBeHidden();
});
test('dropdown menu', async ({ page }) => {
await page.goto('/panel');
await page.getByRole('button', { name: 'Actions' }).click();
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
await menu.getByRole('menuitem', { name: 'Rename' }).click();
await expect(menu).toBeHidden();
});
test('toast auto-dismisses', async ({ page }) => {
await page.goto('/preferences');
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText('Preferences saved')).toBeVisible();
await expect(page.getByText('Preferences saved')).toBeHidden({ timeout: 8000 });
});
});
```
### Error Boundaries
**Use when**: Verifying error boundaries catch rendering errors and show fallback UI.
**Avoid when**: Testing error handling in event handlers or async code—error boundaries only catch render errors.
```typescript
import { test, expect } from '@playwright/test';
test.describe('error boundary', () => {
test('shows fallback on crash', async ({ page }) => {
await page.route('**/api/widgets', (route) => {
route.fulfill({
status: 200,
json: { widgets: null },
});
});
await page.goto('/panel');
await expect(page.getByText('Something went wrong')).toBeVisible();
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
await expect(page.getByRole('navigation')).toBeVisible();
});
test('retry recovers component', async ({ page }) => {
let calls = 0;
await page.route('**/api/widgets', (route) => {
calls++;
if (calls === 1) {
route.fulfill({ status: 200, json: { widgets: null } });
} else {
route.fulfill({ status: 200, json: { widgets: [{ id: 1, name: 'Chart' }] } });
}
});
await page.goto('/panel');
await expect(page.getByText('Something went wrong')).toBeVisible();
await page.getByRole('button', { name: 'Retry' }).click();
await expect(page.getByText('Something went wrong')).toBeHidden();
await expect(page.getByText('Chart')).toBeVisible();
});
});
```
### Component Testing (Experimental)
**Use when**: Testing complex interactive components in isolation—data tables, form wizards, rich editors. Needs real browser but not full app.
**Avoid when**: Component depends heavily on backend data or routing—use E2E instead.
```typescript
// playwright-ct.config.ts
import { defineConfig, devices } from '@playwright/experimental-ct-react';
export default defineConfig({
testDir: './tests/components',
testMatch: '**/*.ct.ts',
use: {
trace: 'on-first-retry',
ctPort: 3100,
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
],
});
```
```typescript
// tests/components/Stepper.ct.ts
import { test, expect } from '@playwright/experimental-ct-react';
import Stepper from '../../src/components/Stepper';
test('increments on click', async ({ mount }) => {
const component = await mount(<Stepper initial={0} />);
await expect(component.getByText('Value: 0')).toBeVisible();
await component.getByRole('button', { name: '+' }).click();
await expect(component.getByText('Value: 1')).toBeVisible();
});
test('fires onChange callback', async ({ mount }) => {
const values: number[] = [];
const component = await mount(
<Stepper initial={0} onChange={(v) => values.push(v)} />
);
await component.getByRole('button', { name: '+' }).click();
await component.getByRole('button', { name: '+' }).click();
expect(values).toEqual([1, 2]);
});
test('respects min boundary', async ({ mount }) => {
const component = await mount(<Stepper initial={0} min={0} />);
await expect(component.getByRole('button', { name: '-' })).toBeDisabled();
});
```
## Setup
### E2E Config (Vite)
```typescript
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? '50%' : undefined,
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
{ name: 'mobile', use: { ...devices['iPhone 14'] } },
],
webServer: {
command: process.env.CI ? 'npm run build && npx vite preview --port 5173' : 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});
```
### CRA vs Vite Differences
| Aspect | Create React App | Vite |
|---|---|---|
| Default port | `3000` | `5173` |
| Build output | `build/` | `dist/` |
| Serve production | `npx serve -s build -l 3000` | `npx vite preview --port 5173` |
| Env var prefix | `REACT_APP_*` | `VITE_*` |
## Framework Tips
### Strict Mode Double Effects
React Strict Mode runs effects twice in development. Tests should be resilient:
- Don't assert exact API call counts in dev mode
- Run against production build for call count assertions, or account for double invocations
### Suspense and Lazy Components
```typescript
test('lazy route loads content', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Analytics' }).click();
await expect(page.getByRole('heading', { name: 'Analytics' })).toBeVisible();
});
```
### Detecting Memory Leaks
```typescript
test('no unmounted state warnings', async ({ page }) => {
const warnings: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'warning' && msg.text().includes('unmounted')) {
warnings.push(msg.text());
}
});
await page.goto('/panel');
await page.getByRole('link', { name: 'Settings' }).click();
await page.goBack();
await page.getByRole('link', { name: 'Profile' }).click();
expect(warnings).toEqual([]);
});
```
## Anti-Patterns
| Don't | Problem | Do Instead |
|---|---|---|
| `page.evaluate(() => store.getState())` | Couples tests to implementation | Assert on UI: `expect(badge).toHaveText('3')` |
| Import components in E2E tests | E2E runs in Node, not browser | Use `@playwright/experimental-ct-react` for components |
| `page.waitForTimeout(500)` after state changes | Timing varies across machines | `expect(locator).toHaveText('value')` auto-retries |
| `page.locator('.MuiButton-root')` | Class names change between versions | `page.getByRole('button', { name: 'Submit' })` |
| Test every component with CT | Overhead for simple components | CT for complex widgets, unit tests for logic, E2E for flows |
| Skip keyboard navigation tests | Accessibility regressions common | Test Tab, Enter, Escape, Arrow interactions |
| Assert on `__REACT_FIBER__` internals | Not stable across versions | Only interact with rendered DOM |
## Related
- [locators.md](../core/locators.md) — locator strategies for any React component library
- [assertions-waiting.md](../core/assertions-waiting.md) — auto-waiting for React state changes
- [forms-validation.md](../testing-patterns/forms-validation.md) — form testing patterns
- [component-testing.md](../testing-patterns/component-testing.md) — in-depth component testing
- [test-architecture.md](../architecture/test-architecture.md) — E2E vs component vs unit decisions
- [nextjs.md](nextjs.md) — Next.js-specific patterns for SSR