| name | react-testing |
| description | React component testing patterns including components, hooks, context, and forms. Covers Vitest Browser Mode with vitest-browser-react (preferred) 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.
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 (Preferred)
Always prefer vitest-browser-react over @testing-library/react. Tests run in a real browser, giving production-accurate rendering, events, and CSS.
Setup
Extend the Browser Mode config from the front-end-testing skill with the React plugin and vitest-browser-react:
npm install -D vitest @vitest/browser-playwright vitest-browser-react @vitejs/plugin-react
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 />)
await screen.getByRole('button', { name: /sign up/i }).click()
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
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(() => {})
const screen = await render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<ThrowsError />
</ErrorBoundary>
)
await expect.element(screen.getByText(/something went wrong/i)).toBeVisible()
spy.mockRestore()
})
Testing Portals
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 manual cleanup
The beforeEach-render and manual cleanup() anti-patterns apply to React exactly as to any UI test — see the front-end-testing skill (Core Anti-Patterns). Use a factory function per test; cleanup is automatic.
Summary Checklist
React-specific checks: