React Testing Best Practices
Overview
This guide covers React testing best practices using Testing Library, focusing on user-centric testing strategies. It complements TypeScript Testing with React-specific patterns and Unit Testing principles.
Why Testing Library?
Testing Library promotes a testing philosophy: test your software the way users use it, not how it's implemented. This approach:
- Catches real bugs: Tests verify actual user-facing behavior, not internal implementation
- Survives refactoring: Tests don't break when you refactor internal logic
- Promotes accessible markup: Query methods encourage proper semantic HTML and ARIA attributes
- Builds confidence: When tests pass, you know the feature works for users
The philosophy shift:
- Old approach (Enzyme): Test state, props, component methods - implementation details
- Testing Library approach: Test what's rendered, how users interact, what they see after interactions
// BAD: Testing implementation (Enzyme-style)
expect(wrapper.state('count')).toBe(1);
expect(wrapper.find(PaymentCard).props().amount).toBe(100);
// GOOD: Testing user behavior (Testing Library)
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText(/100.*USD/)).toBeInTheDocument();
Core Principles
1. Test User Behavior
Tests should verify what users see and do, not how the component achieves it internally. If you refactor the component's internals without changing behavior, tests should still pass.
Example: Testing a counter component
// BAD: Testing implementation
it('increments state when button clicked', () => {
const { result } = renderHook(() => useState(0));
act(() => result.current[1](1)); // calls the setter directly
expect(result.current[0]).toBe(1);
});
// GOOD: Testing user behavior
it('shows incremented count when button clicked', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('1')).toBeInTheDocument();
});
2. Accessibility-First Queries
Testing Library provides multiple query methods. The recommended priority emphasizes accessibility:
- getByRole (most preferred): Queries by ARIA role, enforces accessible markup
- getByLabelText: Queries form fields by their associated label
- getByPlaceholderText: For inputs with placeholders
- getByText: For non-interactive content (headings, paragraphs)
- getByTestId (last resort): When semantic queries aren't possible
Why this priority? Users with screen readers navigate by roles and labels. If your tests can't find elements this way, neither can assistive technology.
3. No Implementation Testing
Avoid testing implementation details: state variables, props, internal methods, hook internals. These are implementation details that users don't care about.
What users care about:
- What's visible on screen
- What happens when they interact
- Whether async operations complete successfully
- Whether errors display correctly
What users don't care about:
- Which state management library you use
- How many times a component re-renders
- Whether you use hooks or class components
- Internal prop names
4. Integration Over Unit
Test components with their children and context providers. Don't mock child components unless they have heavy external dependencies (APIs, third-party services).
Benefits:
- Tests verify real component interactions
- Catches integration bugs between components
- More confidence that features work end-to-end
5. Mock Sparingly
Only mock external dependencies (API calls, browser APIs, third-party services). Use real components, real state management, real routing.
Mock:
- API calls (
fetch,axios) - Browser APIs (
localStorage,navigator.geolocation) - Third-party services (analytics, payment gateways)
Don't mock:
- Child components
- State management (Zustand, Context)
- Routing (use memory router)
- Utility functions
Testing Library Setup
Installation
npm install --save-dev @testing-library/react \
@testing-library/jest-dom \
@testing-library/user-event
Configuration
See TypeScript Testing for Jest configuration.
// src/test/setup.ts
import '@testing-library/jest-dom';
import { cleanup } from '@testing-library/react';
afterEach(() => {
cleanup();
});
Component Testing
Basic Component Test
// PaymentCard.test.tsx
import { render, screen } from '@testing-library/react';
import { PaymentCard } from './PaymentCard';
describe('PaymentCard', () => {
const mockPayment = {
id: 'PAY-123',
amount: 100,
currency: 'USD',
status: 'pending' as const
};
it('should display payment information', () => {
render(<PaymentCard payment={mockPayment} onView={() => {}} />);
// GOOD: Query by role and text (accessibility-first)
expect(screen.getByText('PAY-123')).toBeInTheDocument();
expect(screen.getByText(/100.*USD/)).toBeInTheDocument();
expect(screen.getByText('pending')).toBeInTheDocument();
});
it('should call onView when button clicked', async () => {
const mockOnView = jest.fn();
const user = userEvent.setup();
render(<PaymentCard payment={mockPayment} onView={mockOnView} />);
await user.click(screen.getByRole('button', { name: /view details/i }));
expect(mockOnView).toHaveBeenCalledWith('PAY-123');
expect(mockOnView).toHaveBeenCalledTimes(1);
});
});
Query Priority
Testing Library provides three variants of each query:
- getBy...: Throws error if not found (use for elements that should exist)
- queryBy...: Returns null if not found (use for asserting non-existence)
- findBy...: Returns promise, waits for element (use for async elements)
describe('PaymentForm Query Examples', () => {
it('should use accessible queries', () => {
render(<PaymentForm onSubmit={() => {}} />);
// GOOD: BEST: Query by role - enforces semantic HTML
// Roles: button, textbox, heading, link, checkbox, radio, etc.
const submitButton = screen.getByRole('button', { name: /submit/i });
const heading = screen.getByRole('heading', { name: /payment form/i });
// GOOD: Query by label - best for form fields
// Enforces proper label association
const amountInput = screen.getByLabelText(/amount/i);
const currencySelect = screen.getByLabelText(/currency/i);
// GOOD: OK: Query by placeholder - when label isn't visible
const searchInput = screen.getByPlaceholderText(/search payments/i);
// GOOD: OK: Query by text - for non-interactive content
const instruction = screen.getByText(/enter payment details/i);
// AVOID: Query by test ID - defeats accessibility checks
// Only use when semantic queries truly aren't possible
const element = screen.getByTestId('payment-form');
});
it('should check element does not exist', () => {
render(<PaymentForm onSubmit={() => {}} />);
// GOOD: Use queryBy for asserting non-existence
expect(screen.queryByText(/error/i)).not.toBeInTheDocument();
});
});
Common roles and when to use them:
button: Buttons (including<button>,<input type="button">,role="button")textbox: Text inputs (<input type="text">,<textarea>)heading: Headings (<h1>through<h6>)link: Links (<a href="...">)checkbox: Checkboxes (<input type="checkbox">)radio: Radio buttons (<input type="radio">)combobox: Select dropdowns (<select>)dialog: Modals/dialogs (role="dialog")alert: Alert messages (role="alert")
Accessible name (the { name } option): Elements' accessible name comes from:
- Button: Text content or
aria-label - Input: Associated
<label>text oraria-label - Link: Link text or
aria-label
User Interactions
userEvent (Preferred)
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
describe('PaymentForm', () => {
it('should handle form submission', async () => {
const user = userEvent.setup();
const mockOnSubmit = jest.fn();
render(<PaymentForm onSubmit={mockOnSubmit} />);
// Type into inputs
await user.type(screen.getByLabelText(/amount/i), '100');
await user.type(screen.getByLabelText(/currency/i), 'USD');
// Select from dropdown
await user.selectOptions(
screen.getByLabelText(/vendor/i),
'VENDOR-123'
);
// Click submit
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(mockOnSubmit).toHaveBeenCalledWith({
amount: 100,
currency: 'USD',
vendorId: 'VENDOR-123'
});
});
it('should show validation errors', async () => {
const user = userEvent.setup();
render(<PaymentForm onSubmit={() => {}} />);
// Submit without filling required fields
await user.click(screen.getByRole('button', { name: /submit/i }));
// Validation errors appear
expect(screen.getByText(/amount is required/i)).toBeInTheDocument();
});
});
Keyboard Navigation
it('should support keyboard navigation', async () => {
const user = userEvent.setup();
render(<PaymentForm onSubmit={() => {}} />);
// Tab through form
await user.tab();
expect(screen.getByLabelText(/amount/i)).toHaveFocus();
await user.tab();
expect(screen.getByLabelText(/currency/i)).toHaveFocus();
// Submit with Enter key
await user.keyboard('{Enter}');
expect(screen.getByText(/amount is required/i)).toBeInTheDocument();
});
Async Testing
Testing asynchronous behavior is critical in React applications. Components fetch data, wait for user interactions to complete, and update based on async operations.
Understanding Async Queries
Three approaches for async testing:
-
findBy queries: Convenience method that waits for element to appear
- Returns promise that resolves when element is found
- Rejects if element not found within timeout (default 1000ms)
- Equivalent to
waitFor(() => getBy...)
-
waitFor: Wait for any async condition
- More flexible than findBy
- Use for complex assertions, multiple expectations
- Polls condition function repeatedly until it passes or times out
-
waitForElementToBeRemoved: Wait for element to disappear
- Use when testing loading states that disappear
- More explicit than checking element is not in document
When to use each:
- findBy: Simple cases, waiting for single element to appear
- waitFor: Complex assertions, multiple checks, non-element conditions
- waitForElementToBeRemoved: Explicitly testing element removal
Waiting for Elements
import { render, screen, waitFor, waitForElementToBeRemoved } from '@testing-library/react';
describe('PaymentDetails', () => {
it('should load and display payment data', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
id: 'PAY-123',
amount: 100,
currency: 'USD'
})
});
render(<PaymentDetails paymentId="PAY-123" />);
// Assert loading state appears
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for loading to disappear
await waitForElementToBeRemoved(() => screen.queryByText(/loading/i));
// GOOD: PREFERRED: Use findBy for single element
const paymentId = await screen.findByText('PAY-123');
expect(paymentId).toBeInTheDocument();
// GOOD: ALTERNATIVE: Use waitFor for complex conditions
await waitFor(() => {
expect(screen.getByText(/100.*USD/)).toBeInTheDocument();
expect(screen.getByText('PAY-123')).toBeInTheDocument();
});
});
it('should display error state', async () => {
global.fetch = jest.fn().mockRejectedValue(new Error('Network error'));
render(<PaymentDetails paymentId="PAY-123" />);
// Wait for error message to appear
const errorMessage = await screen.findByText(/failed to load/i);
expect(errorMessage).toBeInTheDocument();
// Verify loading is gone
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
});
Common mistake: Forgetting await with async queries. This causes tests to pass incorrectly.
// BAD: Missing await
it('shows payment', () => {
render(<PaymentDetails paymentId="PAY-123" />);
screen.findByText('PAY-123'); // Returns promise, not checked!
});
// GOOD: Properly awaited
it('shows payment', async () => {
render(<PaymentDetails paymentId="PAY-123" />);
await screen.findByText('PAY-123'); // Actually waits
});
waitFor Options
it('should handle slow API responses', async () => {
global.fetch = jest.fn().mockImplementation(
() => new Promise(resolve =>
setTimeout(() =>
resolve({
ok: true,
json: async () => ({ id: 'PAY-123' })
}),
3000
)
)
);
render(<PaymentDetails paymentId="PAY-123" />);
// Increase timeout for slow operation
await waitFor(
() => {
expect(screen.getByText('PAY-123')).toBeInTheDocument();
},
{ timeout: 5000 }
);
});
Testing Hooks
Testing Custom Hooks
// hooks/usePaymentFilters.test.ts
import { renderHook, act } from '@testing-library/react';
import { usePaymentFilters } from './usePaymentFilters';
describe('usePaymentFilters', () => {
it('should initialize with default values', () => {
const { result } = renderHook(() => usePaymentFilters());
expect(result.current.status).toBe('all');
expect(result.current.currency).toBe('USD');
});
it('should update status filter', () => {
const { result } = renderHook(() => usePaymentFilters());
act(() => {
result.current.setStatus('pending');
});
expect(result.current.status).toBe('pending');
});
it('should reset filters', () => {
const { result } = renderHook(() => usePaymentFilters());
act(() => {
result.current.setStatus('completed');
result.current.setCurrency('EUR');
});
act(() => {
result.current.reset();
});
expect(result.current.status).toBe('all');
expect(result.current.currency).toBe('USD');
});
});
Testing Hooks with Dependencies
// hooks/usePayment.test.ts
import { renderHook, waitFor } from '@testing-library/react';
import { usePayment } from './usePayment';
describe('usePayment', () => {
beforeEach(() => {
global.fetch = jest.fn();
});
it('should fetch payment data', async () => {
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({ id: 'PAY-123', amount: 100 })
});
const { result } = renderHook(() => usePayment('PAY-123'));
expect(result.current.loading).toBe(true);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.payment).toEqual({ id: 'PAY-123', amount: 100 });
expect(result.current.error).toBeNull();
});
it('should refetch data', async () => {
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({ id: 'PAY-123', amount: 100 })
});
const { result } = renderHook(() => usePayment('PAY-123'));
await waitFor(() => expect(result.current.loading).toBe(false));
// Change mock response
(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
json: async () => ({ id: 'PAY-123', amount: 200 })
});
act(() => {
result.current.refetch();
});
await waitFor(() => {
expect(result.current.payment?.amount).toBe(200);
});
});
});
Testing with Context
Wrapping Components with Providers
// test/test-utils.tsx
import { ReactElement } from 'react';
import { render, RenderOptions } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PaymentFormProvider } from '@/contexts/PaymentFormContext';
const createTestQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false }
}
});
interface AllProvidersProps {
children: React.ReactNode;
}
function AllProviders({ children }: AllProvidersProps) {
const queryClient = createTestQueryClient();
return (
<QueryClientProvider client={queryClient}>
<PaymentFormProvider>
{children}
</PaymentFormProvider>
</QueryClientProvider>
);
}
function customRender(
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>
) {
return render(ui, { wrapper: AllProviders, ...options });
}
export * from '@testing-library/react';
export { customRender as render };
// Usage in tests
import { render, screen } from '@/test/test-utils';
it('should access context values', () => {
render(<PaymentWizardStep />);
expect(screen.getByText(/step 1/i)).toBeInTheDocument();
});
Testing Zustand Stores
Testing Store Logic
// stores/usePaymentFilters.test.ts
import { act, renderHook } from '@testing-library/react';
import { usePaymentFilters } from './usePaymentFilters';
describe('usePaymentFilters store', () => {
it('should update filters', () => {
const { result } = renderHook(() => usePaymentFilters());
act(() => {
result.current.setStatus('pending');
});
expect(result.current.status).toBe('pending');
});
it('should persist filters', () => {
const { result: result1 } = renderHook(() => usePaymentFilters());
act(() => {
result1.current.setStatus('completed');
});
// Simulate remount (new hook instance)
const { result: result2 } = renderHook(() => usePaymentFilters());
// Should persist from localStorage
expect(result2.current.status).toBe('completed');
});
});
Testing Components with Zustand
// components/PaymentFilters.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { usePaymentFilters } from '@/stores/usePaymentFilters';
import { PaymentFilters } from './PaymentFilters';
// Reset store before each test
beforeEach(() => {
usePaymentFilters.getState().reset();
});
describe('PaymentFilters', () => {
it('should update store when filter changes', async () => {
const user = userEvent.setup();
render(<PaymentFilters />);
await user.selectOptions(
screen.getByLabelText(/status/i),
'pending'
);
// Verify store was updated
expect(usePaymentFilters.getState().status).toBe('pending');
});
});
Testing React Query
Mocking Query Responses
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import { PaymentList } from './PaymentList';
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false }
}
});
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
describe('PaymentList with React Query', () => {
it('should display payments from API', async () => {
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => [
{ id: 'PAY-1', amount: 100, currency: 'USD' },
{ id: 'PAY-2', amount: 200, currency: 'EUR' }
]
});
render(<PaymentList />, { wrapper: createWrapper() });
expect(screen.getByText(/loading/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('PAY-1')).toBeInTheDocument();
});
expect(screen.getByText('PAY-2')).toBeInTheDocument();
});
it('should handle API errors', async () => {
global.fetch = jest.fn().mockRejectedValue(new Error('API Error'));
render(<PaymentList />, { wrapper: createWrapper() });
await waitFor(() => {
expect(screen.getByText(/error/i)).toBeInTheDocument();
});
});
});
Integration Testing
Testing Component Interactions
describe('Payment Creation Flow', () => {
it('should create payment and update list', async () => {
const user = userEvent.setup();
// Mock POST request
global.fetch = jest.fn().mockImplementation((url, options) => {
if (options?.method === 'POST') {
return Promise.resolve({
ok: true,
json: async () => ({
id: 'PAY-NEW',
amount: 150,
currency: 'USD',
status: 'pending'
})
});
}
// Mock GET request (payment list)
return Promise.resolve({
ok: true,
json: async () => [
{ id: 'PAY-1', amount: 100, currency: 'USD' }
]
});
});
render(<PaymentDashboard />, { wrapper: createWrapper() });
// Wait for initial load
await waitFor(() => {
expect(screen.getByText('PAY-1')).toBeInTheDocument();
});
// Open create form
await user.click(screen.getByRole('button', { name: /create payment/i }));
// Fill form
await user.type(screen.getByLabelText(/amount/i), '150');
await user.type(screen.getByLabelText(/currency/i), 'USD');
// Submit
await user.click(screen.getByRole('button', { name: /submit/i }));
// New payment appears in list
await waitFor(() => {
expect(screen.getByText('PAY-NEW')).toBeInTheDocument();
});
});
});
Accessibility Testing
Basic Accessibility Checks
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
describe('PaymentCard Accessibility', () => {
it('should have no accessibility violations', async () => {
const { container } = render(
<PaymentCard
payment={{ id: 'PAY-1', amount: 100, currency: 'USD', status: 'pending' }}
onView={() => {}}
/>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should be keyboard navigable', async () => {
const user = userEvent.setup();
const mockOnView = jest.fn();
render(<PaymentCard payment={mockPayment} onView={mockOnView} />);
// Tab to button
await user.tab();
// Verify focus
const button = screen.getByRole('button', { name: /view details/i });
expect(button).toHaveFocus();
// Activate with Enter
await user.keyboard('{Enter}');
expect(mockOnView).toHaveBeenCalled();
});
});
Common Anti-Patterns
1. Testing Implementation Details
// BAD: Testing internal state
it('should update state', () => {
const { result } = renderHook(() => useState(0));
act(() => result.current[1](1)); // calls the setter directly
expect(result.current[0]).toBe(1);
});
// GOOD: Test user-visible behavior
it('should increment counter when button clicked', async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole('button', { name: /increment/i }));
expect(screen.getByText('1')).toBeInTheDocument();
});
2. Using getByTestId First
// BAD: Using test IDs as primary query
const button = screen.getByTestId('submit-button');
// GOOD: Use accessible queries
const button = screen.getByRole('button', { name: /submit/i });
3. Not Cleaning Up
// BAD: No cleanup
afterEach(() => {
// Nothing
});
// GOOD: Clean up after each test
afterEach(() => {
cleanup();
jest.clearAllMocks();
});
Further Reading
React Testing Guidelines
- React General - React fundamentals and patterns
- React State Management - Testing Zustand and React Query
- React Performance - Performance testing strategies
Testing Strategy
- Testing Strategy - Overall testing approach
- Unit Testing - General unit testing principles
- Integration Testing - Integration test patterns
- Snapshot Testing - Component snapshot testing
- Visual Regression Testing - Visual testing strategies
- CI Testing - CI pipeline configuration
Language and Framework
- TypeScript Testing - Jest and Stryker configuration
- TypeScript Types - Type-safe test utilities
External Resources
Summary
Key Takeaways
- Test user behavior - what users see and do, not implementation details
- Query by role first - promotes accessible markup and resilient tests
- userEvent over fireEvent - more realistic user interactions
- Wait for async updates with
findByqueries andwaitFor - Test hooks with renderHook - test custom hook logic in isolation
- Wrap with providers using custom render for Context and React Query
- Mock external dependencies - APIs, not internal components
- Integration tests verify component interactions and data flow
- Accessibility testing with jest-axe catches a11y issues
- Avoid testing implementation - state, props, internal methods
Next Steps: Review React Performance for optimization strategies and Unit Testing for general testing principles.