| 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. |
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:
<repo-pm> add --save-dev vitest@<reviewed-version> @vitest/browser-playwright@<reviewed-version> vitest-browser-react@<reviewed-version> @vitejs/plugin-react@<reviewed-version>
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
browser: { },
},
})
Component Testing
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
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.
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:
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
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:
const { result } = await renderHook(() => useAuth(), {
wrapper: ({ children }) => (
<AuthProvider>{children}</AuthProvider>
),
})
Testing Forms
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 />)
screen.(, { : }).()
expect.(screen.()).()
expect.(screen.()).()
expect.(screen.()).()
})
Testing Loading States
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
test('should catch errors with error boundary', async () => {
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
import { page } from 'vitest/browser'
test('should render modal in portal', async () => {
const screen = await render(<Modal isOpen={true}>Modal content</Modal>)
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
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
await act(async () => {
await screen.getByRole('button').click()
})
✅ CORRECT - Locator events handle timing
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
const wrapper = shallow(<MyComponent />);
expect(wrapper.state('isOpen')).toBe(true);
expect(wrapper.instance().handleClick).toBeDefined();
✅ CORRECT - Test rendered output
const screen = await render(<MyComponent />)
await expect.element(screen.getByRole('dialog')).toBeVisible()
3. Shallow rendering
❌ WRONG - Shallow rendering
const wrapper = shallow(<MyComponent />);
✅ CORRECT - Full rendering
await render(<MyComponent />)
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: