| name | testing |
| description | Principles and patterns for writing effective React tests with React Testing Library (Jest/Vitest). Use during implementation for test structure guidance, choosing test patterns, and deciding testing strategies. Emphasizes testing user behavior, not implementation details. |
Testing Principles (React Testing Library)
Principles and patterns for writing effective TypeScript + React tests.
Works with Jest, Vitest, or any other test runner that supports React Testing Library.
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
- Mock API calls using project's existing approach (MSW, nock, jest.mock, etc.)
- Use real hooks and contexts
- Test components with actual dependencies
- Integration-style tests over unit tests
Minimize mocking - use real data closest to the component
- Unit tests: NO mocking of child components, icons, or UI elements
- If testing an icon renders - check the REAL icon, don't mock it
- Use actual component implementations, not jest.mock() replacements
- Mocking is acceptable ONLY for:
- Integration/page-level tests (verifying page has all needed components)
- External API calls (use project's mocking approach consistently)
- Browser APIs that don't exist in test environment (localStorage, etc.)
- The closer your test data is to real component behavior, the more valuable the test
Keep tests DRY - avoid code repetition
- Extract common render setups into helper functions (e.g.,
renderWithProviders)
- Use
beforeEach for shared setup across tests in a describe block
- Create test data factories for consistent mock data
- Share API mock handlers across test files
- Use
test.each() for testing same logic with different inputs
- BUT: Prefer clarity over DRY - some repetition is OK if it makes tests more readable
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
4. Use 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'
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>
)
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 }))
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 (mock APIs, not child components)
- ❌ No getByTestId unless absolutely necessary (use accessibility queries)
- ❌ No comments explaining test methods - test names and code should be self-explanatory
- ❌ No mocking child components, icons, or UI elements in unit tests - use REAL implementations
- ❌ No jest.mock() for components - if icon should render, check the REAL icon
- ❌ No repeated code - use render helpers, data factories, beforeEach, test.each
6. No Linter Disabling Without Approval
NEVER add linter disabling comments to test files without explicit user approval:
eslint-disable, eslint-disable-next-line, eslint-disable-line
@ts-ignore, @ts-expect-error, @ts-nocheck
If a linter rule fails in tests:
- Fix through proper refactoring (better test structure, correct typing)
- If truly unfixable, ASK USER for explicit approval before disabling
- When approved: Add a comment explaining WHY the rule is disabled
const mockData: any = { ... }
const mockData: any = { unexpectedField: 'value' }
validateUser({ name: 123 })
6. Verify Tests Actually Catch Bugs
When writing unit tests for components, verify each test actually works:
- Run a single test
- Introduce a bug in the code that the test should catch
- Confirm the test fails
- Revert the breakage
- Move to the next test
This ensures tests are meaningful and not just passing by accident.
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} />)
const input = screen.getByRole('textbox', { name: /search/i })
await user.type(input, 'react testing')
await user.click(screen.getByRole('button', { name: /search/i }))
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} />)
await user.click(screen.getByRole('button', { name: /search/i }))
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'
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())
expect(result.current.isLoading).toBe(true)
expect(result.current.users).toEqual([])
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.users).toEqual(mockUsers)
expect(result.current.error).toBeNull()
})
test('handles error when fetch fails', async () => {
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'
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' />)
expect(screen.getByText(/loading/i)).toBeInTheDocument()
await waitFor(() => {
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()
})
expect(screen.getByText(/john doe/i)).toBeInTheDocument()
expect(screen.getByText(/john@example.com/i)).toBeInTheDocument()
})
test('displays error when load fails', async () => {