14 KiB
name, description
| name | description |
|---|---|
| testing | Principles and patterns for writing effective React tests with Jest and React Testing Library. Use during implementation for test structure guidance, choosing test patterns, and deciding testing strategies. Emphasizes testing user behavior, not implementation details. |
Testing Principles (Jest + React Testing Library)
Principles and patterns for writing effective TypeScript + React tests.
When to Use
- During implementation (tests + code in parallel)
- When testing strategy is unclear
- When structuring component or hook tests
- When choosing between test patterns
Testing Philosophy
Test user behavior, not implementation details
- Test what users see and do
- Use accessible queries (getByRole, getByLabelText)
- Avoid testing internal state or methods
- Focus on public API
Prefer real implementations over mocks
- Use MSW (Mock Service Worker) for API mocking
- Use real hooks and contexts
- Test components with actual dependencies
- Integration-style tests over unit tests
Coverage targets
- Pure components/hooks: 100% coverage
- Container components: Integration tests for user flows
- Custom hooks: Test all branches and edge cases
Workflow
1. Identify What to Test
Pure Components/Hooks (Leaf types):
- No external dependencies
- Predictable output for given input
- Test all branches, edge cases, errors
- Aim for 100% coverage
Examples:
- Button, Input, Card (presentational components)
- useDebounce, useLocalStorage (utility hooks)
- Validation functions, formatters
Container Components (Orchestrating types):
- Coordinate multiple components
- Manage state and side effects
- Test user workflows, not implementation
- Integration tests with real dependencies
Examples:
- LoginContainer, UserProfileContainer
- Feature-level components with data fetching
2. Choose Test Structure
test.each() - Use when:
- Testing same logic with different inputs
- Each test case is simple (no conditionals)
- Type-safe with TypeScript
describe/it blocks - Use when:
- Testing complex user flows
- Need setup/teardown per test
- Testing different scenarios
React Testing Library Suite - Always use:
- render() for components
- screen queries (getByRole, getByText, etc.)
- user-event for interactions
- waitFor for async operations
3. Write Tests Next to Implementation
// src/features/auth/components/LoginForm.tsx
// src/features/auth/components/LoginForm.test.tsx
4. Use Real Implementations
// ✅ Good: Real implementations
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AuthProvider } from '../context/AuthContext'
import { LoginForm } from './LoginForm'
// MSW for API mocking (real HTTP)
import { rest } from 'msw'
import { setupServer } from 'msw/node'
const server = setupServer(
rest.post('/api/login', (req, res, ctx) => {
return res(ctx.json({ token: 'fake-token' }))
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
test('user can log in', async () => {
const user = userEvent.setup()
render(
<AuthProvider>
<LoginForm />
</AuthProvider>
)
// Real user interactions
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /log in/i }))
// Assert on user-visible changes
expect(await screen.findByText(/welcome/i)).toBeInTheDocument()
})
5. Avoid Common Pitfalls
- ❌ No waitFor(() => {}, { timeout: 5000 }) with arbitrary delays
- ❌ No testing implementation details (state, internal methods)
- ❌ No shallow rendering (use full render)
- ❌ No excessive mocking (use MSW for APIs)
- ❌ No getByTestId unless absolutely necessary (use accessibility queries)
Test Patterns
Pattern 1: Table-Driven Tests (test.each)
import { render, screen } from '@testing-library/react'
import { Button } from './Button'
describe('Button', () => {
test.each([
{ variant: 'primary', expectedClass: 'btn-primary' },
{ variant: 'secondary', expectedClass: 'btn-secondary' },
{ variant: 'danger', expectedClass: 'btn-danger' }
])('renders $variant variant with class $expectedClass', ({ variant, expectedClass }) => {
render(<Button variant={variant} label='Click me' onClick={() => {}} />)
const button = screen.getByRole('button', { name: /click me/i })
expect(button).toHaveClass(expectedClass)
})
test.each([
{ isDisabled: true, shouldBeDisabled: true },
{ isDisabled: false, shouldBeDisabled: false }
])('when isDisabled=$isDisabled, button is disabled=$shouldBeDisabled',
({ isDisabled, shouldBeDisabled }) => {
render(<Button label='Click me' onClick={() => {}} isDisabled={isDisabled} />)
const button = screen.getByRole('button')
if (shouldBeDisabled) {
expect(button).toBeDisabled()
} else {
expect(button).toBeEnabled()
}
}
)
})
Pattern 2: Component with User Interactions
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { SearchBox } from './SearchBox'
describe('SearchBox', () => {
test('calls onSearch when user types and submits', async () => {
const user = userEvent.setup()
const onSearch = jest.fn()
render(<SearchBox onSearch={onSearch} />)
// Type in search box
const input = screen.getByRole('textbox', { name: /search/i })
await user.type(input, 'react testing')
// Submit form
await user.click(screen.getByRole('button', { name: /search/i }))
// Assert callback called
expect(onSearch).toHaveBeenCalledWith('react testing')
expect(onSearch).toHaveBeenCalledTimes(1)
})
test('shows validation error for empty search', async () => {
const user = userEvent.setup()
const onSearch = jest.fn()
render(<SearchBox onSearch={onSearch} />)
// Submit without typing
await user.click(screen.getByRole('button', { name: /search/i }))
// Assert error message
expect(screen.getByText(/search cannot be empty/i)).toBeInTheDocument()
expect(onSearch).not.toHaveBeenCalled()
})
})
Pattern 3: Testing Custom Hooks
import { renderHook, waitFor } from '@testing-library/react'
import { useUsers } from './useUsers'
// MSW setup for API
import { rest } from 'msw'
import { setupServer } from 'msw/node'
const mockUsers = [
{ id: '1', name: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' }
]
const server = setupServer(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.json(mockUsers))
})
)
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
describe('useUsers', () => {
test('fetches users successfully', async () => {
const { result } = renderHook(() => useUsers())
// Initially loading
expect(result.current.isLoading).toBe(true)
expect(result.current.users).toEqual([])
// Wait for data to load
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
// Assert users loaded
expect(result.current.users).toEqual(mockUsers)
expect(result.current.error).toBeNull()
})
test('handles error when fetch fails', async () => {
// Override handler to return error
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(ctx.status(500), ctx.json({ message: 'Server error' }))
})
)
const { result } = renderHook(() => useUsers())
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.users).toEqual([])
expect(result.current.error).toBeTruthy()
})
})
Pattern 4: Testing with Context
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { AuthProvider } from '../context/AuthContext'
import { ProtectedRoute } from './ProtectedRoute'
// Helper to render with providers
function renderWithAuth(ui: React.ReactElement, { user = null } = {}) {
return render(
<AuthProvider initialUser={user}>
{ui}
</AuthProvider>
)
}
describe('ProtectedRoute', () => {
test('redirects to login when user is not authenticated', () => {
renderWithAuth(<ProtectedRoute><div>Protected Content</div></ProtectedRoute>)
expect(screen.queryByText(/protected content/i)).not.toBeInTheDocument()
expect(screen.getByText(/please log in/i)).toBeInTheDocument()
})
test('shows content when user is authenticated', () => {
const user = { id: '1', email: 'test@example.com', name: 'Test User' }
renderWithAuth(
<ProtectedRoute><div>Protected Content</div></ProtectedRoute>,
{ user }
)
expect(screen.getByText(/protected content/i)).toBeInTheDocument()
expect(screen.queryByText(/please log in/i)).not.toBeInTheDocument()
})
})
Pattern 5: Async Operations (waitFor)
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UserProfile } from './UserProfile'
test('loads and displays user profile', async () => {
render(<UserProfile userId='123' />)
// Assert loading state
expect(screen.getByText(/loading/i)).toBeInTheDocument()
// Wait for content to appear
await waitFor(() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()
})
// Assert loaded content
expect(screen.getByText(/john doe/i)).toBeInTheDocument()
expect(screen.getByText(/john@example.com/i)).toBeInTheDocument()
})
test('displays error when load fails', async () => {
// Mock API to return error
server.use(
rest.get('/api/users/:id', (req, res, ctx) => {
return res(ctx.status(404), ctx.json({ message: 'User not found' }))
})
)
render(<UserProfile userId='999' />)
// Wait for error message
await waitFor(() => {
expect(screen.getByText(/user not found/i)).toBeInTheDocument()
})
})
Testing Queries Priority
Use queries in this order (from most to least preferred):
-
getByRole - Best for accessibility
screen.getByRole('button', { name: /submit/i }) screen.getByRole('textbox', { name: /email/i }) -
getByLabelText - Good for form fields
screen.getByLabelText(/email address/i) -
getByPlaceholderText - When label isn't available
screen.getByPlaceholderText(/enter your email/i) -
getByText - For non-interactive elements
screen.getByText(/welcome back/i) -
getByTestId - Last resort only
screen.getByTestId('custom-component')
MSW Setup
Mock Service Worker for realistic API mocking:
// src/test/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
// src/test/mocks/handlers.ts
import { rest } from 'msw'
export const handlers = [
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json([
{ id: '1', name: 'User 1' },
{ id: '2', name: 'User 2' }
])
)
}),
rest.post('/api/login', (req, res, ctx) => {
const { email, password } = req.body as any
if (email === 'test@example.com' && password === 'password') {
return res(
ctx.status(200),
ctx.json({ token: 'fake-token', user: { id: '1', email } })
)
}
return res(
ctx.status(401),
ctx.json({ message: 'Invalid credentials' })
)
})
]
// src/test/setup.ts (in Jest config)
import { server } from './mocks/server'
beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
Key Principles
See reference.md for detailed principles:
- Test user behavior, not implementation
- Use accessibility queries (getByRole)
- Prefer real implementations over mocks
- MSW for API mocking
- waitFor for async, avoid arbitrary timeouts
- 100% coverage for pure components/hooks
- Integration tests for user flows
Coverage Strategy
Pure components (100% coverage):
- All prop combinations
- All user interactions
- All conditional renders
- Error states
Container components (integration tests):
- Complete user flows
- Error scenarios
- Loading states
- Success paths
Custom hooks (100% coverage):
- All return values
- All branches
- Error handling
- Edge cases
Common Testing Patterns
Testing Forms
// Fill form fields
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
// Submit form
await user.click(screen.getByRole('button', { name: /submit/i }))
// Assert success
expect(await screen.findByText(/success/i)).toBeInTheDocument()
Testing Lists
// Assert list items
const items = screen.getAllByRole('listitem')
expect(items).toHaveLength(3)
// Assert specific item
expect(screen.getByText(/item 1/i)).toBeInTheDocument()
Testing Modals
// Open modal
await user.click(screen.getByRole('button', { name: /open modal/i }))
// Assert modal visible
expect(screen.getByRole('dialog')).toBeInTheDocument()
// Close modal
await user.click(screen.getByRole('button', { name: /close/i }))
// Assert modal hidden
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
Testing Navigation
import { MemoryRouter } from 'react-router-dom'
function renderWithRouter(ui: React.ReactElement, { initialEntries = ['/'] } = {}) {
return render(
<MemoryRouter initialEntries={initialEntries}>
{ui}
</MemoryRouter>
)
}
test('navigates to user profile on click', async () => {
const user = userEvent.setup()
renderWithRouter(<UserList />)
await user.click(screen.getByText(/john doe/i))
expect(screen.getByText(/user profile/i)).toBeInTheDocument()
})
See reference.md for complete testing patterns and examples.