React Testing Patterns
Setup
// vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
coverage: { thresholds: { lines: 80, functions: 80, branches: 80 } },
},
})
// src/test/setup.ts
import '@testing-library/jest-dom'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => { cleanup() })
Component Rendering
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
// Wrap with providers
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={createMemoryRouter([{ path: '/', element: ui }])} />
</QueryClientProvider>
)
}
Accessible Queries (priority order)
// 1. By role — best (mirrors how users/AT perceive the element)
screen.getByRole('button', { name: /submit/i })
screen.getByRole('textbox', { name: /email/i })
screen.getByRole('heading', { level: 1 })
screen.getByRole('link', { name: /home/i })
// 2. By label text
screen.getByLabelText(/email address/i)
// 3. By placeholder text
screen.getByPlaceholderText(/search/i)
// 4. By text
screen.getByText(/welcome back/i)
// 5. By test id (last resort)
screen.getByTestId('hero-section')
User Interactions
describe('LoginForm', () => {
it('submits with valid credentials', async () => {
const user = userEvent.setup()
const
render(<LoginForm />)
await user.type(screen.getByLabelText(/email/i), 'alice@example.com')
await user.type(screen.getByLabelText(/password/i), 'secret123')
await user.click(screen.getByRole('button', { name: /log in/i }))
expect(onSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'secret123',
})
})
it('shows error for invalid email', async () => {
const user = userEvent.setup()
render(<LoginForm />)
await user.type(screen.getByLabelText(/email/i), 'not-an-email')
await user.click(screen.getByRole('button', { name: /log in/i }))
expect(screen.getByRole('alert')).toHaveTextContent(/invalid email/i)
})
})
Async & API Testing with MSW
// src/test/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/users', () => {
return HttpResponse.json([
{ id: '1', name: 'Alice', email: 'alice@example.com' },
])
}),
http.post('/api/users', async ({ request }) => {
const body = await request.json() as any
return HttpResponse.json({ id: '2', ...body }, { status: 201 })
}),
]
// src/test/setup.ts (add to existing)
import { setupServer } from 'msw/node'
import { handlers } from './mocks/handlers'
const server = setupServer(...handlers)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
// Component test with data fetching
it('renders user list', async () => {
renderWithProviders(<UserList />)
// Wait for async content
expect(await screen.findByText('Alice')).toBeInTheDocument()
})
// Override handler for specific test
it('shows error on API failure', async () => {
server.use(
http.get('/api/users', () => HttpResponse.json({ error: 'Server error' }, { status: 500 }))
)
renderWithProviders(<UserList />)
expect(await screen.findByRole('alert')).toBeInTheDocument()
})
Custom Hook Testing
import { renderHook, act } from '@testing-library/react'
describe('useCounter', () => {
it('increments count', () => {
const { result } = renderHook(() => useCounter(0))
act(() => { result.current.increment() })
expect(result.current.count).toBe(1)
})
it('resets to initial value', () => {
const { result } = renderHook(() => useCounter(5))
act(() => {
result.current.increment()
result.current.reset()
})
expect(result.current.count).toBe(5)
})
})
Snapshot Testing (selective use)
// Use for stable, purely-presentational components
it('renders loading skeleton', () => {
const { container } = render(<UserSkeleton />)
expect(container.firstChild).toMatchSnapshot()
})
Accessibility Testing
import { axe, toHaveNoViolations } from 'jest-axe'
expect.extend(toHaveNoViolations)
it('has no accessibility violations', async () => {
const { container } = render(<LoginForm />)
const results = await axe(container)
expect(results).toHaveNoViolations()
})
Testing Common Patterns
Modal / Dialog
it('opens and closes modal', async () => {
const user = userEvent.setup()
render(<ConfirmDialog />)
await user.click(screen.getByRole('button', { name: /delete/i }))
expect(screen.getByRole('dialog')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /cancel/i }))
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
Form Validation
it('disables submit button while loading', async () => {
const user = userEvent.setup()
render(<CreatePostForm />)
await user.type(screen.getByLabelText(/title/i), 'My Post')
await user.click(screen.getByRole('button', { name: /create/i }))
expect(screen.getByRole('button', { name: /creating/i })).toBeDisabled()
})