react-testing
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
// vitest.config.ts
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 — useawait- Returns a
screenscoped 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
// ✅ CORRECT - Test component behavior
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();
});
// ❌ WRONG - Testing implementation
it('should set name state', () => {
const wrapper = mount(<UserProfile name="Alice" />);
expect(wrapper.state('name')).toBe('Alice'); // Internal state!
});
Testing Props
// ✅ CORRECT - Test how props affect rendered output
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
// ✅ CORRECT - Test what user sees in different states
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 hookact()- Wrap state updatesrerender()- 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);
// Test with different initial value
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
// ✅ CORRECT - Wrap component in provider
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 />);
// Submit empty form
await user.click(screen.getByRole('button', { name: /sign up/i }));
// Validation errors appear
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()userEventmethodsfireEventwaitFor,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(); // Automatic since RTL 9!
});
✅ CORRECT - No cleanup needed
// Cleanup happens automatically after each test
3. beforeEach render pattern
❌ WRONG - Shared render in beforeEach
let button;
beforeEach(() => {
render(<MyComponent />);
button = screen.getByRole('button'); // Shared state across tests
});
it('test 1', () => {
// Uses shared button from beforeEach
});
✅ CORRECT - Factory function per test
const renderComponent = () => {
render(<MyComponent />);
return {
button: screen.getByRole('button'),
};
};
it('test 1', () => {
const { button } = renderComponent(); // Fresh state
});
For factory patterns, see testing skill.
4. Testing component internals
❌ WRONG - Accessing component internals
const wrapper = shallow(<MyComponent />);
expect(wrapper.state('isOpen')).toBe(true); // Internal state
expect(wrapper.instance().handleClick).toBeDefined(); // Internal method
✅ CORRECT - Test rendered output
render(<MyComponent />);
expect(screen.getByRole('dialog')).toBeInTheDocument(); // What user sees
5. Shallow rendering
❌ WRONG - Shallow rendering
const wrapper = shallow(<MyComponent />);
// Child components not rendered - incomplete test
✅ CORRECT - Full rendering
render(<MyComponent />);
// Full component tree rendered - realistic test
Why: Shallow rendering hides integration bugs between parent/child components.
Testing Loading States
it('should show loading then data', async () => {
render(<UserList />);
// Initially loading
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for data
await screen.findByText(/alice/i);
// Loading gone
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
Testing Error Boundaries
it('should catch errors with error boundary', () => {
// Suppress console.error for this test
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>);
// Portal renders outside root, but Testing Library finds it
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>
);
// Initially fallback
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for component
await screen.findByText(/lazy content/i);
});
Summary Checklist
React-specific checks:
- Preferred: Using
vitest-browser-reactwith Vitest Browser Mode (real browser) - Fallback: Using
@testing-library/reactif Browser Mode not yet configured - All Playwright/Browser Mode tests are idempotent (no shared state between tests)
- Using
renderHook()for custom hooks - Using
wrapperoption for context providers - No manual
act()calls (handled automatically) - No manual
cleanup()calls (automatic) - Testing component output, not internal state
- Using factory functions, not
beforeEachrender - Using
expect.element()for auto-retrying assertions (Browser Mode) - Following TDD workflow (see
tddskill) - Using general UI testing patterns (see
front-end-testingskill) - Using test factories for data (see
testingskill)
More from citypaul/dotfiles
tdd
Test-Driven Development workflow. Use for ALL code changes - features, bug fixes, refactoring. TDD is non-negotiable.
10testing
Testing patterns for behavior-driven tests. Use when writing tests, creating test factories, structuring test files, or deciding what to test. Do NOT use for UI-specific testing (see front-end-testing or react-testing skills).
10mutation-testing
Mutation testing patterns for verifying test effectiveness. Use when analyzing branch code to find weak or missing tests.
10typescript-strict
TypeScript strict mode patterns including schema-first development, branded types, type vs interface guidance, and tsconfig strict flags. Use when writing TypeScript code, defining types or schemas, or reviewing type safety. For immutability and pure function patterns, see the functional skill.
10planning
Planning work in small, known-good increments. Use when starting significant work or breaking down complex tasks.
9refactoring
Refactoring assessment and patterns. Use after mutation testing validates test strength (MUTATE phase) to assess improvement opportunities.
9