| 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), load the front-end-testing skill. For TDD workflow, load the tdd skill.
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
npm install -D vitest @vitest/browser-playwright vitest-browser-react @vitejs/plugin-react
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser-playwright'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
browser: {
enabled: true,
provider: playwright(),
headless: true,
instances: [{ browser: 'chromium' }],
},
},
})
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() is async — use await
- Returns a
screen scoped to the rendered component
- Use
expect.element() for auto-retrying assertions
- No
act() wrapper needed — CDP events + retry handle timing
- 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
test('should show error message when login fails', async () => {
server.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
import { renderHook } from 'vitest-browser-react'
test('should toggle value', async () => {
const { result } = 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>
),
})
Legacy: @testing-library/react Patterns
The patterns below apply when using @testing-library/react with jsdom. Prefer vitest-browser-react for new projects.
Testing React Components
React components are just functions that return JSX. Test them like functions: inputs (props) → output (rendered DOM).
Basic Component Testing
it('should display user name when provided', () => {
render(<UserProfile name="Alice" email="alice@example.com" />);
expect(screen.getByText(/alice/i)).toBeInTheDocument();
expect(screen.getByText(/alice@example.com/i)).toBeInTheDocument();
});
it('should set name state', () => {
const wrapper = mount(<UserProfile name="Alice" />);
expect(wrapper.state('name')).toBe('Alice');
});
Testing Props
it('should call onSubmit when form submitted', async () => {
const handleSubmit = vi.fn();
const user = userEvent.setup();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/email/i), 'test@example.com');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'test@example.com',
});
});
Testing Conditional Rendering
it('should show error message when login fails', async () => {
server.use(
http.post('/api/login', () => {
return HttpResponse.json({ error: 'Invalid credentials' }, { status: 401 });
})
);
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), 'wrong@example.com');
await user.click(screen.getByRole('button', { name: /submit/i }));
await screen.findByText(/invalid credentials/i);
});
Testing React Hooks
Custom Hooks with renderHook
Built into @testing-library/react (import directly, no separate package needed):
import { renderHook } from '@testing-library/react';
it('should toggle value', () => {
const { result } = renderHook(() => useToggle(false));
expect(result.current.value).toBe(false);
act(() => {
result.current.toggle();
});
expect(result.current.value).toBe(true);
});
Pattern:
result.current - Current return value of hook
act() - Wrap state updates
rerender() - Re-run hook with new props
Hooks with Props
it('should accept initial value', () => {
const { result, rerender } = renderHook(
({ initialValue }) => useCounter(initialValue),
{ initialProps: { initialValue: 10 } }
);
expect(result.current.count).toBe(10);
rerender({ initialValue: 20 });
expect(result.current.count).toBe(20);
});
Testing Context
wrapper Option
For hooks that need context providers:
const { result } = renderHook(() => useAuth(), {
wrapper: ({ children }) => (
<AuthProvider>
{children}
</AuthProvider>
),
});
expect(result.current.user).toBeNull();
act(() => {
result.current.login({ email: 'test@example.com' });
});
expect(result.current.user).toEqual({ email: 'test@example.com' });
Multiple Providers
const AllProviders = ({ children }) => (
<AuthProvider>
<ThemeProvider>
<RouterProvider>
{children}
</RouterProvider>
</ThemeProvider>
</AuthProvider>
);
const { result } = renderHook(() => useMyHook(), {
wrapper: AllProviders,
});
Testing Components with Context
const renderWithAuth = (ui, { user = null, ...options } = {}) => {
return render(
<AuthProvider initialUser={user}>
{ui}
</AuthProvider>,
options
);
};
it('should show user menu when authenticated', () => {
renderWithAuth(<Dashboard />, {
user: { name: 'Alice', role: 'admin' },
});
expect(screen.getByRole('button', { name: /user menu/i })).toBeInTheDocument();
});
Testing Forms
Controlled Inputs
it('should update input value as user types', async () => {
const user = userEvent.setup();
render(<SearchInput />);
const input = screen.getByLabelText(/search/i);
await user.type(input, 'react');
expect(input).toHaveValue('react');
});
Form Submissions
it('should submit form with user input', async () => {
const handleSubmit = vi.fn();
const user = userEvent.setup();
render(<RegistrationForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText(/name/i), 'Alice');
await user.type(screen.getByLabelText(/email/i), 'alice@example.com');
await user.type(screen.getByLabelText(/password/i), 'password123');
await user.click(screen.getByRole('button', { name: /sign up/i }));
expect(handleSubmit).toHaveBeenCalledWith({
name: 'Alice',
email: 'alice@example.com',
password: 'password123',
});
});
Form Validation
it('should show validation errors for invalid input', async () => {
const user = userEvent.setup();
render(<RegistrationForm />);
await user.click(screen.getByRole('button', { name: /sign up/i }));
expect(screen.getByText(/name is required/i)).toBeInTheDocument();
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
});
React-Specific Anti-Patterns
1. Unnecessary act() wrapping
❌ WRONG - Manual act() everywhere
act(() => {
render(<MyComponent />);
});
await act(async () => {
await user.click(button);
});
✅ CORRECT - RTL handles it
render(<MyComponent />);
await user.click(button);
Modern RTL auto-wraps:
render()
userEvent methods
fireEvent
waitFor, findBy
When you DO need manual act():
- Custom hook state updates (
renderHook)
- Direct state mutations (rare, usually bad practice)
2. Manual cleanup() calls
❌ WRONG - Manual cleanup
afterEach(() => {
cleanup();
});
✅ CORRECT - No cleanup needed
3. beforeEach render pattern
❌ WRONG - Shared render in beforeEach
let button;
beforeEach(() => {
render(<MyComponent />);
button = screen.getByRole('button');
});
it('test 1', () => {
});
✅ CORRECT - Factory function per test
const renderComponent = () => {
render(<MyComponent />);
return {
button: screen.getByRole('button'),
};
};
it('test 1', () => {
const { button } = renderComponent();
});
For factory patterns, see testing skill.
4. 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
render(<MyComponent />);
expect(screen.getByRole('dialog')).toBeInTheDocument();
5. Shallow rendering
❌ WRONG - Shallow rendering
const wrapper = shallow(<MyComponent />);
✅ CORRECT - Full rendering
render(<MyComponent />);
Why: Shallow rendering hides integration bugs between parent/child components.
Testing Loading States
it('should show loading then data', async () => {
render(<UserList />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await screen.findByText(/alice/i);
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
Testing Error Boundaries
it('should catch errors with error boundary', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<ThrowsError />
</ErrorBoundary>
);
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument();
spy.mockRestore();
});
Testing Portals
it('should render modal in portal', () => {
render(<Modal isOpen={true}>Modal content</Modal>);
expect(screen.getByText(/modal content/i)).toBeInTheDocument();
});
Testing Library queries the entire document, so portals work automatically.
Testing Suspense
it('should show fallback then content', async () => {
render(
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await screen.findByText(/lazy content/i);
});
Summary Checklist
React-specific checks: