react-testing

React component testing patterns including components, hooks, context, and forms. Covers Vitest Browser Mode with vitest-browser-react and @testing-library/react. Use when testing React applications. For general UI testing patterns, see the front-end-testing skill.

citypaul/.dotfiles213 installsMITSynced Aug 26

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI
---
name: react-testing
description: React component testing patterns including components, hooks, context, and forms. Covers Vitest Browser Mode with vitest-browser-react and @testing-library/react. Use when testing React applications. For general UI testing patterns, see the front-end-testing skill.
license: MIT
---

# React Testing

For general UI testing patterns (queries, events, async, accessibility, MSW), load the `front-end-testing` skill. For TDD workflow, load the `tdd` skill.

For flow logic driving the component, load `xstate`: the machine is tested headlessly and the component test touches only the DOM, so a component test must never assert machine state. If the component under test holds a `submitting`/`isLoading` flag in `useState`, that is the signal the flow escaped its machine — `xstate` owns that call. For performance changes to the same code, load `react-performance`, whose rule is that behaviour tests stay unchanged and green.

Follow the `tdd` skill's canonical fast-feedback and watcher-lifecycle policy plus the `front-end-testing` skill's browser-specific differences. React adds no separate Vitest graph guarantee: prefer the repository-owned watcher, use diff-selected watch only under the canonical version/configuration proof, and keep every affected app/package consumer eligible through the root graph. Exact files remain RED/debug-only. At PR readiness, stop watchers and apply the target repository's mutation policy plus complete non-watch UI/project gate.

**Deep-dive resources** are in the `resources/` directory. Load them on demand:

| Resource | Load when... |
|----------|-------------|
| `resources/testing-library-react-legacy.md` | Working in a `@testing-library/react` + jsdom codebase — sync render, `screen` queries, imported `act`, render helpers, legacy form/hook/context examples |

---

## Vitest Browser Mode with React

Prefer `vitest-browser-react` when the claim depends on real rendering,
events, focus, CSS, accessibility, or browser APIs and the repository supports
the harness or the added cost is justified. Keep an existing stable
`@testing-library/react`/jsdom harness, or use a lighter environment, when it
already proves pure hook, provider, or component logic.

### Setup

Extend the Browser Mode config from the `front-end-testing` skill with the React
plugin and `vitest-browser-react`. Apply that skill's repository-package-manager,
exact-version, authorization, and local-binary setup policy:

```bash
<repo-pm> add --save-dev vitest@<reviewed-version> @vitest/browser-playwright@<reviewed-version> vitest-browser-react@<reviewed-version> @vitejs/plugin-react@<reviewed-version>
```

```typescript
// vitest.config.ts — same as front-end-testing Browser Mode config, plus:
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  test: {
    browser: { /* unchanged from front-end-testing setup */ },
  },
})
```

### Component Testing

```tsx
import { render } from 'vitest-browser-react'
import { expect, test } from 'vitest'

test('should display user name when provided', async () => {
  const screen = await render(<UserProfile name="Alice" email="alice@example.com" />)

  await expect.element(screen.getByText(/alice/i)).toBeVisible()
  await expect.element(screen.getByText(/alice@example.com/i)).toBeVisible()
})
```

**Key differences from `@testing-library/react`:**
- `render()` and `renderHook()` are async — use `await`
- Returns a `screen` scoped to the rendered component
- Use `expect.element()` for auto-retrying assertions
- No `act()` wrapper needed for component interactions via locators — CDP events + retry handle timing. `renderHook` state updates still need `act` (returned by `renderHook`, see below)
- Auto-cleanup happens before each test (not after), so components stay visible for debugging

### Testing Props and Callbacks

```tsx
test('should call onSubmit when form submitted', async () => {
  const handleSubmit = vi.fn()
  const screen = await render(<LoginForm onSubmit={handleSubmit} />)

  await screen.getByLabelText(/email/i).fill('test@example.com')
  await screen.getByRole('button', { name: /submit/i }).click()

  expect(handleSubmit).toHaveBeenCalledWith({
    email: 'test@example.com',
  })
})
```

### Testing Conditional Rendering (with MSW)

Browser Mode tests run in a real browser, so use MSW's `setupWorker` (`msw/browser`) — not `setupServer`. Start the worker in a setup file and override per test with `worker.use()`. Full setup: `front-end-testing` skill, `resources/msw.md`.

```tsx
import { http, HttpResponse } from 'msw'
import { worker } from '../vitest.browser.setup'

test('should show error message when login fails', async () => {
  worker.use(
    http.post('/api/login', () => {
      return HttpResponse.json({ error: 'Invalid credentials' }, { status: 401 })
    })
  )

  const screen = await render(<LoginForm />)

  await screen.getByLabelText(/email/i).fill('wrong@example.com')
  await screen.getByRole('button', { name: /submit/i }).click()

  await expect.element(screen.getByText(/invalid credentials/i)).toBeVisible()
})
```

### Testing Hooks with renderHook

`renderHook()` is async and returns `act` alongside `result` — use that `act` for hook state updates:

```tsx
import { renderHook } from 'vitest-browser-react'

test('should toggle value', async () => {
  const { result, act } = await renderHook(() => useToggle(false))

  expect(result.current.value).toBe(false)

  await act(() => {
    result.current.toggle()
  })

  expect(result.current.value).toBe(true)
})
```

### Testing Context Providers

```tsx
test('should show user menu when authenticated', async () => {
  const screen = await render(
    <AuthProvider initialUser={{ name: 'Alice', role: 'admin' }}>
      <Dashboard />
    </AuthProvider>
  )

  await expect.element(screen.getByRole('button', { name: /user menu/i })).toBeVisible()
})
```

For hooks that need context:
```tsx
const { result } = await renderHook(() => useAuth(), {
  wrapper: ({ children }) => (
    <AuthProvider>{children}</AuthProvider>
  ),
})
```

### Testing Forms

```tsx
test('should submit form with user input', async () => {
  const handleSubmit = vi.fn()
  const screen = await render(<RegistrationForm onSubmit={handleSubmit} />)

  await screen.getByLabelText(/name/i).fill('Alice')
  await screen.getByLabelText(/email/i).fill('alice@example.com')
  await screen.getByLabelText(/password/i).fill('password123')
  await screen.getByRole('button', { name: /sign up/i }).click()

  expect(handleSubmit).toHaveBeenCalledWith({
    name: 'Alice',
    email: 'alice@example.com',
    password: 'password123',
  })
})

test('should show validation errors for invalid input', async () => {
  const screen = await render(<RegistrationForm />)

  // Submit empty form
  await screen.getByRole('button', { name: /sign up/i }).click()

  // Validation errors appear
  await expect.element(screen.getByText(/name is required/i)).toBeVisible()
  await expect.element(screen.getByText(/email is required/i)).toBeVisible()
  await expect.element(screen.getByText(/password is required/i)).toBeVisible()
})
```

### Testing Loading States

```tsx
test('should show loading then data', async () => {
  const screen = await render(<UserList />)

  await expect.element(screen.getByText(/loading/i)).toBeVisible()

  await expect.element(screen.getByText(/alice/i)).toBeVisible()
  await expect.element(screen.getByText(/loading/i)).not.toBeInTheDocument()
})
```

### Testing Error Boundaries

```tsx
test('should catch errors with error boundary', async () => {
  // Suppress console.error noise for this test
  const spy = vi.spyOn(console, 'error').mockImplementation(() => {})

  try {
    const screen = await render(
      <ErrorBoundary fallback={<div>Something went wrong</div>}>
        <ThrowsError />
      </ErrorBoundary>
    )

    await expect.element(screen.getByText(/something went wrong/i)).toBeVisible()
  } finally {
    spy.mockRestore()
  }
})
```

### Testing Portals

```tsx
import { page } from 'vitest/browser'

test('should render modal in portal', async () => {
  const screen = await render(<Modal isOpen={true}>Modal content</Modal>)

  // Portal renders outside the component root; query the page instead
  await expect.element(page.getByText(/modal content/i)).toBeVisible()
})
```

The returned `screen` is scoped to the rendered component — for portal content, use the document-wide `page` from `vitest/browser`.

### Testing Suspense

```tsx
test('should show fallback then content', async () => {
  const screen = await render(
    <Suspense fallback={<div>Loading...</div>}>
      <LazyComponent />
    </Suspense>
  )

  await expect.element(screen.getByText(/loading/i)).toBeVisible()

  await expect.element(screen.getByText(/lazy content/i)).toBeVisible()
})
```

### React Server Components

RSCs can't be tested in Browser Mode component tests — they execute on the server, not in the browser. Test them with e2e tests (Playwright against a running app) or unit tests of logic extracted from the component. Client components (`'use client'`) test normally with `vitest-browser-react`.

---

## React-Specific Anti-Patterns

### 1. Unnecessary act() wrapping

❌ **WRONG - Manual act() around renders and interactions**
```tsx
await act(async () => {
  await screen.getByRole('button').click()
})
```

✅ **CORRECT - Locator events handle timing**
```tsx
await screen.getByRole('button').click()
```

**When you DO need `act()`:** hook state updates via `renderHook` (use the `act` it returns). In `@testing-library/react`, RTL auto-wraps `render`/`userEvent`/`waitFor` — see `resources/testing-library-react-legacy.md`.

### 2. Testing component internals

❌ **WRONG - Accessing component internals**
```tsx
const wrapper = shallow(<MyComponent />);
expect(wrapper.state('isOpen')).toBe(true); // Internal state
expect(wrapper.instance().handleClick).toBeDefined(); // Internal method
```

✅ **CORRECT - Test rendered output**
```tsx
const screen = await render(<MyComponent />)
await expect.element(screen.getByRole('dialog')).toBeVisible() // What user sees
```

### 3. Shallow rendering

❌ **WRONG - Shallow rendering**
```tsx
const wrapper = shallow(<MyComponent />);
// Child components not rendered - incomplete test
```

✅ **CORRECT - Full rendering**
```tsx
await render(<MyComponent />)
// Full component tree rendered - realistic test
```

**Why:** Shallow rendering hides integration bugs between parent/child components.

### 4. Shared renders and cleanup ownership

Shared mutable render state is the defect, not a lifecycle hook. An isolated
`beforeEach` may create fresh state for each non-concurrent test; use a helper
only when repeated or nested setup becomes clearer. Testing Library cleanup is
automatic only when the harness provides its expected global `afterEach`;
otherwise register an explicit `afterEach(() => cleanup())` in test setup.

---

## Summary Checklist

React-specific checks:

- [ ] Use `vitest-browser-react` when the claim needs browser-observable behavior and repository support/cost fit
- [ ] Keep `@testing-library/react` when the stable lighter harness proves the component or hook contract (see `resources/testing-library-react-legacy.md`)
- [ ] All Playwright/Browser Mode tests are idempotent (no shared state between tests)
- [ ] `render()`/`renderHook()` awaited (they are async in vitest-browser-react)
- [ ] Using `renderHook()` for custom hooks, with its returned `act` for state updates
- [ ] Using `wrapper` option for context providers
- [ ] No manual `act()` around renders or locator interactions
- [ ] Cleanup is either verified automatic for this harness or registered once in test setup
- [ ] MSW via `setupWorker`/`worker.use()` in Browser Mode (not `setupServer`)
- [ ] Testing component output, not internal state
- [ ] Setup is isolated per test; lifecycle hooks may create fresh state, and helpers are used only when repeated or nested setup becomes clearer
- [ ] Using `expect.element()` for auto-retrying assertions (Browser Mode)
- [ ] RSCs tested via e2e or extracted logic, not Browser Mode component tests
- [ ] Following TDD workflow (see `tdd` skill)
- [ ] GREEN/REFACTOR used complete affected feedback derived by the runner, workspace orchestrator, or repository mapping—or the documented widened fallback when no reliable graph exists—rather than hand-picked test files; the complete repository PR test gate is current and includes the full configured UI suite
- [ ] Using general UI testing patterns (see `front-end-testing` skill)
- [ ] Factories are used when repeated or nested data becomes clearer; simple one-off values stay inline (see `testing` skill)

More Testing skills

← All Testing skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY