25 KiB
Testing Reference (Jest + React Testing Library)
Core Philosophy
"The more your tests resemble the way your software is used, the more confidence they can give you." - Kent C. Dodds
Test user behavior, not implementation details
Implementation detail: Internal state, methods, component lifecycle User behavior: What users see, click, type, and read
Query Priority
Always use queries in this priority order:
1. Accessible queries (Preferred)
getByRole - Most preferred, tests accessibility
// Buttons
screen.getByRole('button', { name: /submit/i })
screen.getByRole('button', { name: /cancel/i })
// Links
screen.getByRole('link', { name: /home/i })
// Inputs
screen.getByRole('textbox', { name: /email/i })
screen.getByRole('textbox', { name: /password/i })
// Checkboxes
screen.getByRole('checkbox', { name: /remember me/i })
// Headings
screen.getByRole('heading', { name: /welcome/i, level: 1 })
// Lists
screen.getByRole('list')
screen.getAllByRole('listitem')
// Dialogs/Modals
screen.getByRole('dialog')
screen.getByRole('alertdialog')
getByLabelText - Great for forms
screen.getByLabelText(/email address/i)
screen.getByLabelText(/password/i)
// With exact match
screen.getByLabelText('Email Address', { exact: false })
2. Semantic queries
getByPlaceholderText
screen.getByPlaceholderText(/enter your email/i)
getByText
screen.getByText(/welcome back/i)
screen.getByText(/loading/i)
// Partial match
screen.getByText(/welcome/i) // Matches "Welcome back!"
getByDisplayValue - For form fields with values
screen.getByDisplayValue(/john@example.com/i)
3. Test IDs (Last resort)
Only use when accessibility queries don't work:
screen.getByTestId('custom-complex-component')
Query Variants
get* - Element must exist
// Throws if not found
const button = screen.getByRole('button')
query* - Element may not exist
// Returns null if not found
const button = screen.queryByRole('button')
expect(button).not.toBeInTheDocument()
find* - Element appears asynchronously
// Returns promise, waits up to 1000ms by default
const button = await screen.findByRole('button')
getAll*, queryAll*, findAll* - Multiple elements
const items = screen.getAllByRole('listitem')
expect(items).toHaveLength(5)
User Interactions (user-event)
Always use @testing-library/user-event over fireEvent:
import userEvent from '@testing-library/user-event'
// Setup user (required in v14+)
const user = userEvent.setup()
// Click
await user.click(element)
await user.dblClick(element)
await user.tripleClick(element)
// Type
await user.type(input, 'Hello World')
await user.clear(input)
// Keyboard
await user.keyboard('{Enter}')
await user.keyboard('{Escape}')
await user.tab()
// Hover
await user.hover(element)
await user.unhover(element)
// Select
await user.selectOptions(select, 'option1')
// Upload file
const file = new File(['hello'], 'hello.txt', { type: 'text/plain' })
await user.upload(input, file)
// Copy/paste
await user.copy()
await user.paste('clipboard content')
Async Testing
waitFor - Wait for condition
import { waitFor } from '@testing-library/react'
// Wait for element to appear
await waitFor(() => {
expect(screen.getByText(/success/i)).toBeInTheDocument()
})
// Wait for element to disappear
await waitFor(() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()
})
// Custom timeout (default: 1000ms)
await waitFor(
() => {
expect(screen.getByText(/loaded/i)).toBeInTheDocument()
},
{ timeout: 3000 }
)
// Custom interval (default: 50ms)
await waitFor(
() => {
expect(screen.getByText(/loaded/i)).toBeInTheDocument()
},
{ interval: 100 }
)
findBy - Shorthand for waitFor + getBy
// Instead of:
await waitFor(() => {
expect(screen.getByText(/loaded/i)).toBeInTheDocument()
})
// Use:
await screen.findByText(/loaded/i)
// They're equivalent, but findBy is more concise
❌ DON'T use arbitrary timeouts
// ❌ Bad: Arbitrary wait
await new Promise(resolve => setTimeout(resolve, 1000))
// ✅ Good: Wait for specific condition
await waitFor(() => {
expect(screen.getByText(/loaded/i)).toBeInTheDocument()
})
MSW (Mock Service Worker)
Setup
// 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: 'Alice', email: 'alice@example.com' },
{ id: '2', name: 'Bob', email: 'bob@example.com' }
])
)
}),
rest.post('/api/users', (req, res, ctx) => {
const newUser = req.body
return res(
ctx.status(201),
ctx.json({ id: '3', ...newUser })
)
}),
rest.get('/api/users/:id', (req, res, ctx) => {
const { id } = req.params
return res(
ctx.status(200),
ctx.json({ id, name: 'Alice', email: 'alice@example.com' })
)
})
]
// src/test/mocks/server.ts
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
// src/test/setup.ts (imported in Jest config)
import '@testing-library/jest-dom'
import { server } from './mocks/server'
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())
Override handlers in tests
import { rest } from 'msw'
import { server } from '@/test/mocks/server'
test('handles server error', async () => {
// Override for this test only
server.use(
rest.get('/api/users', (req, res, ctx) => {
return res(
ctx.status(500),
ctx.json({ message: 'Internal server error' })
)
})
)
render(<UserList />)
expect(await screen.findByText(/error loading users/i)).toBeInTheDocument()
})
Testing Patterns
Pattern: Form Submission
test('submits form with valid data', async () => {
const user = userEvent.setup()
const onSubmit = jest.fn()
render(<ContactForm onSubmit={onSubmit} />)
// Fill form
await user.type(screen.getByLabelText(/name/i), 'John Doe')
await user.type(screen.getByLabelText(/email/i), 'john@example.com')
await user.type(screen.getByLabelText(/message/i), 'Hello world')
// Submit
await user.click(screen.getByRole('button', { name: /submit/i }))
// Assert
expect(onSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@example.com',
message: 'Hello world'
})
})
test('shows validation errors for invalid email', async () => {
const user = userEvent.setup()
render(<ContactForm onSubmit={jest.fn()} />)
await user.type(screen.getByLabelText(/email/i), 'invalid-email')
await user.click(screen.getByRole('button', { name: /submit/i }))
expect(screen.getByText(/invalid email address/i)).toBeInTheDocument()
})
Pattern: Data Fetching
test('loads and displays user data', async () => {
render(<UserProfile userId='123' />)
// Loading state
expect(screen.getByText(/loading/i)).toBeInTheDocument()
// Wait for data to load
expect(await screen.findByText(/alice/i)).toBeInTheDocument()
// Loading indicator gone
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()
})
test('handles loading error', async () => {
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' />)
expect(await screen.findByText(/user not found/i)).toBeInTheDocument()
})
Pattern: Component with Context
// Test helper
function renderWithProviders(
ui: React.ReactElement,
{
initialAuth = null,
theme = 'light'
} = {}
) {
return render(
<AuthProvider initialUser={initialAuth}>
<ThemeProvider initialTheme={theme}>
{ui}
</ThemeProvider>
</AuthProvider>
)
}
test('shows user menu when authenticated', () => {
const user = { id: '1', name: 'Alice', email: 'alice@example.com' }
renderWithProviders(<Navigation />, { initialAuth: user })
expect(screen.getByText(/alice/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /logout/i })).toBeInTheDocument()
})
test('shows login button when not authenticated', () => {
renderWithProviders(<Navigation />)
expect(screen.getByRole('button', { name: /login/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /logout/i })).not.toBeInTheDocument()
})
Pattern: Custom Hooks
import { renderHook, waitFor } from '@testing-library/react'
test('useDebounce delays value update', async () => {
jest.useFakeTimers()
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'initial', delay: 500 } }
)
expect(result.current).toBe('initial')
// Update value
rerender({ value: 'updated', delay: 500 })
// Still old value
expect(result.current).toBe('initial')
// Fast-forward time
jest.advanceTimersByTime(500)
// Now updated
await waitFor(() => {
expect(result.current).toBe('updated')
})
jest.useRealTimers()
})
Pattern: Testing Hooks with Dependencies
function renderHookWithProviders<T>(
hook: () => T,
{
initialAuth = null
} = {}
) {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<AuthProvider initialUser={initialAuth}>
{children}
</AuthProvider>
)
return renderHook(hook, { wrapper })
}
test('useAuth returns current user', () => {
const user = { id: '1', name: 'Alice' }
const { result } = renderHookWithProviders(
() => useAuth(),
{ initialAuth: user }
)
expect(result.current.user).toEqual(user)
expect(result.current.isAuthenticated).toBe(true)
})
Pattern: Modal/Dialog
test('opens and closes modal', async () => {
const user = userEvent.setup()
render(<ModalExample />)
// Modal not visible initially
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
// Open modal
await user.click(screen.getByRole('button', { name: /open modal/i }))
// Modal visible
const modal = screen.getByRole('dialog')
expect(modal).toBeInTheDocument()
expect(within(modal).getByText(/modal content/i)).toBeInTheDocument()
// Close modal (via close button)
await user.click(within(modal).getByRole('button', { name: /close/i }))
// Modal hidden
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
test('closes modal on escape key', async () => {
const user = userEvent.setup()
render(<ModalExample />)
await user.click(screen.getByRole('button', { name: /open modal/i }))
expect(screen.getByRole('dialog')).toBeInTheDocument()
// Press escape
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
Pattern: Lists and Filtering
test('filters users by search term', async () => {
const user = userEvent.setup()
render(<UserList />)
// Wait for users to load
await waitFor(() => {
expect(screen.getAllByRole('listitem')).toHaveLength(10)
})
// Type in search
await user.type(screen.getByRole('textbox', { name: /search/i }), 'alice')
// Wait for filtered results
await waitFor(() => {
const items = screen.getAllByRole('listitem')
expect(items).toHaveLength(1)
expect(within(items[0]).getByText(/alice/i)).toBeInTheDocument()
})
})
Pattern: Navigation (React Router)
import { MemoryRouter, Route, Routes } from 'react-router-dom'
function renderWithRouter(
ui: React.ReactElement,
{ initialEntries = ['/'] } = {}
) {
return render(
<MemoryRouter initialEntries={initialEntries}>
<Routes>
<Route path='/' element={<HomePage />} />
<Route path='/users/:id' element={ui} />
<Route path='/login' element={<LoginPage />} />
</Routes>
</MemoryRouter>
)
}
test('navigates to user profile on click', async () => {
const user = userEvent.setup()
renderWithRouter(<UserList />)
// Click user link
await user.click(screen.getByRole('link', { name: /alice/i }))
// Assert navigation occurred (check URL-dependent content)
expect(await screen.findByRole('heading', { name: /alice's profile/i })).toBeInTheDocument()
})
Pattern: Error Boundaries
test('error boundary catches component errors', () => {
// Suppress console.error for this test
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
const ThrowError = () => {
throw new Error('Test error')
}
render(
<ErrorBoundary fallback={<div>Something went wrong</div>}>
<ThrowError />
</ErrorBoundary>
)
expect(screen.getByText(/something went wrong/i)).toBeInTheDocument()
consoleSpy.mockRestore()
})
Coverage Targets
The architecture principle: Push logic into leaf types for maximum testability. Strive to have most of your business logic in small, focused, dependency-free leaf components/hooks/functions.
Leaf Components/Hooks/Functions
Definition: Pure, self-contained units with no external dependencies (no API calls, database access, file system). They contain core business logic and can freely compose other leaf types.
Key Characteristics:
- ✅ Can depend on other leaf types - Domain types composing domain types (e.g.,
OrderusesMoney,ShippingAddress) - ❌ Cannot depend on external systems - No API calls, database access, file system operations
- ✅ Deterministic - Same input always gives same output
- ✅ No side effects - Pure functions or immutable objects
- ✅ Testable without mocks - Just instantiate and test
Examples:
- Branded types:
Email,UserId,Pricewith validation - Domain models:
Order,Money,ShippingAddress(can use other leaf types) - Validation functions:
validateEmail(),parseDate() - Custom hooks (pure):
useValidation(),useDebounce(),useLocalStorage() - Utility functions:
formatCurrency(),parseAddress() - Presentational components:
Button,Card,Input(UI-only, no business logic)
Coverage Target: 100% unit test coverage
Why:
- Leaf types contain core business logic and must be bulletproof
- No external dependencies means easy to test in isolation (no mocks needed)
- High confidence in these building blocks enables safe composition
- Most bugs happen in complex logic - leaf types isolate that complexity
How to test:
- Test only the public API (exports)
- Use real implementations, not mocks
- Cover happy path, edge cases, and error cases
- For hooks: Test with
@testing-library/react-hooksor render in test component - For components: Use React Testing Library with user-centric tests
Example - Leaf Hook:
// useDebounce.ts - Pure hook with no dependencies
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debouncedValue
}
// useDebounce.test.ts - 100% coverage
describe('useDebounce', () => {
it('returns initial value immediately', () => {
const { result } = renderHook(() => useDebounce('initial', 500))
expect(result.current).toBe('initial')
})
it('updates value after delay', async () => {
const { result, rerender } = renderHook(
({ value, delay }) => useDebounce(value, delay),
{ initialProps: { value: 'initial', delay: 500 } }
)
rerender({ value: 'updated', delay: 500 })
expect(result.current).toBe('initial') // Still old value
await waitFor(() => {
expect(result.current).toBe('updated') // Now updated
}, { timeout: 600 })
})
it('cancels pending update on unmount', () => {
const { result, unmount } = renderHook(() => useDebounce('value', 500))
unmount()
// No error should be thrown
})
})
Example - Leaf Types Composing Other Leaf Types:
// All leaf types - can freely compose each other
class Money {
constructor(private readonly cents: number) {}
add(other: Money): Money {
return new Money(this.cents + other.cents)
}
multiply(factor: number): Money {
return new Money(Math.round(this.cents * factor))
}
}
class ShippingAddress {
constructor(
readonly street: string,
readonly city: string
) {}
isRemoteLocation(): boolean {
return this.city === 'Remote'
}
}
class Order {
constructor(
private items: CartItem[],
private shipping: ShippingAddress // ✅ Leaf using another leaf
) {}
calculateTotal(): Money { // ✅ Returns a leaf type
const subtotal = this.items.reduce(
(sum, item) => sum.add(Money.dollars(item.price)),
Money.dollars(0)
)
const shippingCost = this.shipping.isRemoteLocation()
? Money.dollars(15)
: Money.dollars(5)
return subtotal.add(shippingCost)
}
}
// Testing is trivial - no mocks needed!
describe('Order', () => {
it('calculates total with shipping', () => {
const order = new Order(
[{ price: 100 }],
new ShippingAddress('123 Main', 'Springfield')
)
expect(order.calculateTotal()).toEqual(Money.dollars(105))
})
it('adds remote shipping for remote locations', () => {
const order = new Order(
[{ price: 100 }],
new ShippingAddress('456 Main', 'Remote')
)
expect(order.calculateTotal()).toEqual(Money.dollars(115))
})
})
Example - NOT Leaf Types (External Dependencies):
// NOT a leaf - depends on external API
class OrderService {
constructor(private api: ApiClient) {} // ❌ External dependency
async fetchOrder(id: string): Promise<Order> {
return this.api.get(`/orders/${id}`) // ❌ API call
}
async saveOrder(order: Order): Promise<void> {
await this.api.post('/orders', order) // ❌ API call
}
}
// NOT a leaf - depends on database
class OrderRepository {
constructor(private db: Database) {} // ❌ External dependency
async findById(id: string): Promise<Order> {
return this.db.query('SELECT * FROM orders WHERE id = ?', [id]) // ❌ DB access
}
}
// NOT a leaf - custom hook that fetches data
function useOrder(orderId: string) {
const [order, setOrder] = useState<Order | null>(null)
useEffect(() => {
fetch(`/api/orders/${orderId}`) // ❌ API call
.then(res => res.json())
.then(setOrder)
}, [orderId])
return order
}
// Testing these requires mocks or test servers (integration tests)
Key Insight: Complex domain models like Order, Money, ShippingAddress are ALL leaf types because they're pure logic. They can compose each other freely. The boundary is external systems, not other application types.
Orchestrating Components/Functions
Definition: Coordinate multiple leaf types, hooks, or services. They compose smaller pieces but contain minimal logic themselves.
Examples:
- Feature components:
UserProfile,LoginForm,CheckoutFlow - Page components:
HomePage,DashboardPage - Context providers:
AuthProvider,ThemeProvider - Container components: Connect data (hooks/APIs) to presentation
- Coordinator functions:
processCheckout()that calls multiple services
Coverage Target: Integration test coverage
Why:
- Test seams and interactions between leaf types
- Verify correct composition and data flow
- Can have some overlap with leaf type coverage
- Focus on behavior from user perspective
How to test:
- Test entire feature flows
- Use MSW (Mock Service Worker) for API calls
- Test user interactions with
userEvent - Verify multiple components working together
- Test loading/error/success states
Example - Orchestrating Component:
// LoginForm.tsx - Orchestrates multiple pieces
export function LoginForm() {
const { login } = useAuth() // Leaf hook
const { validateEmail } = useValidation() // Leaf hook
const navigate = useNavigate()
const handleSubmit = async (email: string, password: string) => {
if (!validateEmail(email)) return
await login(email, password)
navigate('/dashboard')
}
return <LoginFormView onSubmit={handleSubmit} />
}
// LoginForm.test.tsx - Integration test (not 100% coverage required)
test('successful login navigates to dashboard', async () => {
const user = userEvent.setup()
// Mock API
server.use(
http.post('/api/login', () => {
return HttpResponse.json({ token: 'abc123' })
})
)
render(<LoginForm />)
await user.type(screen.getByLabelText(/email/i), 'user@example.com')
await user.type(screen.getByLabelText(/password/i), 'password123')
await user.click(screen.getByRole('button', { name: /sign in/i }))
// Verify navigation happened
await waitFor(() => {
expect(window.location.pathname).toBe('/dashboard')
})
})
Architectural Benefits
When you follow this pattern:
- Easier testing: Leaf types are trivial to test (100% coverage achievable)
- Better composition: Small, focused pieces are easier to combine
- Easier refactoring: Changes isolated to leaf types
- Lower cognitive load: Each piece has single responsibility
- Reusability: Leaf types naturally reusable across features
Anti-pattern: Putting complex business logic directly in orchestrating components makes testing hard and coupling high. Always extract to leaf types.
Coverage Strategy Summary
| Type | Coverage Target | Test Approach | Example |
|---|---|---|---|
| Leaf | 100% unit tests | Isolated, no mocks | useDebounce(), Email type, Button |
| Orchestrating | Integration tests | User flows, MSW | LoginForm, UserProfile, AuthProvider |
Jest Matchers (jest-dom)
Common assertions from @testing-library/jest-dom:
// Presence
expect(element).toBeInTheDocument()
expect(element).not.toBeInTheDocument()
// Visibility
expect(element).toBeVisible()
expect(element).not.toBeVisible()
// Enabled/Disabled
expect(button).toBeEnabled()
expect(button).toBeDisabled()
// Form values
expect(input).toHaveValue('text')
expect(checkbox).toBeChecked()
expect(checkbox).not.toBeChecked()
// Text content
expect(element).toHaveTextContent('Hello')
expect(element).toHaveTextContent(/hello/i)
// Attributes
expect(element).toHaveAttribute('href', '/home')
expect(element).toHaveClass('active')
expect(element).toHaveStyle({ color: 'red' })
// Focus
expect(input).toHaveFocus()
// Accessibility
expect(button).toHaveAccessibleName('Submit')
expect(button).toHaveAccessibleDescription('Submit the form')
Coverage Configuration
jest.config.js:
module.exports = {
collectCoverageFrom: [
'src/**/*.{ts,tsx}',
'!src/**/*.d.ts',
'!src/**/*.stories.tsx',
'!src/test/**',
'!src/index.tsx'
],
coverageThresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80
},
// Pure components/hooks: 100%
'./src/shared/components/**/*.{ts,tsx}': {
branches: 100,
functions: 100,
lines: 100,
statements: 100
},
'./src/shared/hooks/**/*.{ts,tsx}': {
branches: 100,
functions: 100,
lines: 100,
statements: 100
}
}
}
Best Practices Summary
✅ DO
- Test user behavior, not implementation
- Use accessible queries (getByRole, getByLabelText)
- Use user-event for interactions
- Use MSW for API mocking
- Wait for conditions with waitFor/findBy
- Colocate tests with components
- Write descriptive test names
- Test error scenarios
- Test loading states
- Test accessibility
❌ DON'T
- Test implementation details (state, lifecycle)
- Use shallow rendering
- Use arbitrary timeouts (setTimeout)
- Test private methods
- Mock everything (prefer real implementations)
- Use getByTestId unless necessary
- Rely on snapshots for critical logic
- Write tests that depend on each other
- Ignore console errors/warnings
Common Pitfalls
1. Testing implementation details
// ❌ Bad: Testing internal state
expect(component.state.isOpen).toBe(true)
// ✅ Good: Testing user-visible behavior
expect(screen.getByRole('dialog')).toBeInTheDocument()
2. Not cleaning up
// ❌ Bad: No cleanup between tests
afterEach(() => {
// Test state leaks to next test
})
// ✅ Good: Proper cleanup
afterEach(() => {
server.resetHandlers()
jest.clearAllMocks()
})
3. Arbitrary waits
// ❌ Bad: Arbitrary timeout
await new Promise(r => setTimeout(r, 1000))
// ✅ Good: Wait for specific condition
await waitFor(() => expect(screen.getByText(/loaded/i)).toBeInTheDocument())
4. Overusing mocks
// ❌ Bad: Mock everything
jest.mock('./useAuth', () => ({ useAuth: () => mockAuth }))
jest.mock('./api', () => ({ fetchUser: mockFetch }))
// ✅ Good: Use real implementations with MSW
// useAuth uses real context
// API calls intercepted by MSW
Testing Checklist
Before committing tests:
- Tests use accessible queries (getByRole, getByLabelText)
- User interactions use user-event, not fireEvent
- Async operations use waitFor/findBy, not setTimeout
- API calls mocked with MSW, not jest.mock
- Tests are independent (no shared state)
- Error scenarios covered
- Loading states covered
- Tests read like user stories
- No implementation details tested
- Coverage meets targets