| 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 () => {
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' />)
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')
API Mocking Setup
Use the project's existing API mocking approach consistently (MSW, nock, jest.mock, etc.).
MSW example (adapt to your project's approach):
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
export const server = setupServer(...handlers)
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' })
)
})
]
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
- Mock APIs consistently using project's approach
- 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
await user.type(screen.getByLabelText(/email/i), 'test@example.com')
await user.click(screen.getByRole('button', { name: /submit/i }))
expect(await screen.findByText(/success/i)).toBeInTheDocument()
Testing Lists
const items = screen.getAllByRole('listitem')
expect(items).toHaveLength(3)
expect(screen.getByText(/item 1/i)).toBeInTheDocument()
Testing Modals
await user.click(screen.getByRole('button', { name: /open modal/i }))
expect(screen.getByRole('dialog')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /close/i }))
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()
})
Acceptance Criteria
All criteria must be met before testing is considered complete.
Mandatory Requirements (Must Pass)
-
All Tests Pass
-
Coverage Targets Met
-
Test Quality Standards
-
Test Verification
Verification Workflow
IMPORTANT: Detect available scripts from the project's package.json before running checks.
# Run all tests
Run test command from package.json (e.g., test, test:unit, vitest)
# Check coverage
Run test command with coverage flag (e.g., test --coverage)
# Verify specific test catches bugs:
# 1. Run single test
# 2. Break the code it tests
# 3. Confirm test fails
# 4. Revert breakage
Testing Completion Checklist
โ
TESTING ACCEPTANCE CRITERIA
Test Execution:
[ ] All tests pass
[ ] No .skip or .only left in code
[ ] No test errors or warnings
Coverage:
[ ] Leaf components/hooks: 100%
[ ] User flows: Key paths covered
[ ] Error states: Covered
[ ] Edge cases: Covered
Test Quality:
[ ] Accessibility queries used (getByRole, getByLabelText)
[ ] User behavior tested, not implementation
[ ] No arbitrary timeouts
[ ] API mocking consistent with project approach
[ ] Tests verified to catch actual bugs
[ ] Unit tests use real components (no mocking icons, child components, UI elements)
[ ] Mocking only for integration tests, external APIs, browser APIs
[ ] DRY principle followed (shared setup, data factories, render helpers)
[ ] No linter disabling without user approval
[ ] Any approved disables have explanatory comments
Testing complete: All boxes checked โ
What Blocks Completion
The following will BLOCK testing completion:
- Any failing test
- Coverage below targets for leaf types
- Tests using implementation details (internal state, private methods)
- Unverified tests (not confirmed to catch bugs)
.only or .skip left in test files
- Unit tests that mock child components, icons, or UI elements (use real implementations)
- Excessive code repetition (extract render helpers, data factories, use beforeEach/test.each)
- Linter disabling comments without explicit user approval
- Approved linter disabling without explanatory comment (why was it necessary)
Coverage Exceptions (Document When Used)
Acceptable reasons for < 100% coverage on leaf types:
- Platform-specific code paths (document which)
- Third-party library edge cases
- Explicitly unreachable defensive code
Document any coverage exceptions in test file comments.
Additional Resources
- reference.md - Complete testing patterns and examples
- examples.md - Real-world testing examples:
- Testing presentational components (pure UI, 100% coverage)
- Testing container components (integration tests)
- Testing custom hooks with API mocking
- Testing with Readonly props
- Testing state updates (functional vs direct)
- Testing composition patterns (compound components, hook composition)