Back to Sanity

React Application Testing

.agents/skills/playwright-best-practices/frameworks/react.md

5.24.017.5 KB
Original Source

React Application Testing

Table of Contents

  1. Patterns
  2. Setup
  3. Framework Tips
  4. Anti-Patterns
  5. 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, 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).

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('[email protected]')
    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('[email protected]')
    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

AspectCreate React AppVite
Default port30005173
Build outputbuild/dist/
Serve productionnpx serve -s build -l 3000npx vite preview --port 5173
Env var prefixREACT_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'tProblemDo Instead
page.evaluate(() => store.getState())Couples tests to implementationAssert on UI: expect(badge).toHaveText('3')
Import components in E2E testsE2E runs in Node, not browserUse @playwright/experimental-ct-react for components
page.waitForTimeout(500) after state changesTiming varies across machinesexpect(locator).toHaveText('value') auto-retries
page.locator('.MuiButton-root')Class names change between versionspage.getByRole('button', { name: 'Submit' })
Test every component with CTOverhead for simple componentsCT for complex widgets, unit tests for logic, E2E for flows
Skip keyboard navigation testsAccessibility regressions commonTest Tab, Enter, Escape, Arrow interactions
Assert on __REACT_FIBER__ internalsNot stable across versionsOnly interact with rendered DOM