
If your React tests break every time you refactor a component’s internals — even when the user-facing behavior stays the same — your testing approach is the problem. React Testing Library solves this by encouraging tests that interact with components the way a real user would: finding elements by their visible text, clicking buttons by their labels, and asserting on what appears on screen. This guide covers setting up React Testing Library, choosing the right queries, simulating user events, testing async behavior, handling forms, and wrapping components with providers. By the end, you’ll write tests that catch real bugs without coupling to implementation details.
What Is React Testing Library?
React Testing Library is a lightweight testing utility built on top of DOM Testing Library that provides methods for rendering React components, querying the rendered output, and simulating user interactions. It was created by Kent C. Dodds with a guiding principle: “The more your tests resemble the way your software is used, the more confidence they can give you.”
Unlike Enzyme, which exposed component internals like state and lifecycle methods, React Testing Library deliberately hides implementation details. You cannot access component.state() or call component.instance(). Instead, you test what the user sees and does — text on the screen, form inputs, buttons, and navigation. As a result, your tests survive refactors that change internal state management, hooks, or component structure without changing the user experience. For a broader look at testing frameworks, see our guide on unit testing with Jest and Vitest.
Setting Up React Testing Library
Most React setups include React Testing Library by default. If you’re starting fresh or adding it to an existing project:
# Install React Testing Library and related packages
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event
# If using Vitest instead of Jest
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event vitest jsdom
Configure your test setup file to include the custom matchers:
// tests/setup.ts
import '@testing-library/jest-dom';
// vitest.config.ts (if using Vitest)
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
globals: true,
},
});
The @testing-library/jest-dom package adds custom matchers like toBeInTheDocument(), toBeVisible(), and toHaveTextContent() that make assertions more readable and produce clearer error messages when tests fail.
Querying Elements: Choosing the Right Query
React Testing Library provides several query types, and choosing the right one is essential for writing resilient tests. The queries follow a priority order based on accessibility:
Query Priority (Use in This Order)
| Priority | Query | Use When |
|---|---|---|
| 1 | getByRole | Element has an ARIA role (buttons, headings, links, inputs) |
| 2 | getByLabelText | Form inputs with associated labels |
| 3 | getByPlaceholderText | Input with a placeholder (when no label exists) |
| 4 | getByText | Non-interactive elements with visible text |
| 5 | getByDisplayValue | Inputs with a current value |
| 6 | getByAltText | Images with alt text |
| 7 | getByTestId | Last resort when no semantic query works |
// src/components/LoginForm.test.tsx
import { render, screen } from '@testing-library/react';
import { LoginForm } from './LoginForm';
test('renders login form with accessible elements', () => {
render(<LoginForm />);
// Best: query by role (verifies accessibility)
expect(screen.getByRole('heading', { name: /sign in/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
// Good: query by label (verifies form accessibility)
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText(/password/i)).toBeInTheDocument();
// Acceptable: query by text for non-interactive elements
expect(screen.getByText(/forgot your password/i)).toBeInTheDocument();
// Avoid: query by test ID (doesn't verify accessibility)
// expect(screen.getByTestId('login-form')).toBeInTheDocument();
});
The getByRole query is the strongest choice because it tests both the visual output and the accessibility markup simultaneously. If getByRole('button', { name: /submit/i }) fails, it means either the button doesn’t exist or it’s not properly accessible — both are real bugs worth catching.
Query Variants: get, query, and find
Each query comes in three variants with different behavior for missing elements:
// getBy — throws if element is not found (use for elements that should exist)
const button = screen.getByRole('button', { name: /submit/i });
// queryBy — returns null if not found (use for asserting absence)
expect(screen.queryByText(/error message/i)).not.toBeInTheDocument();
// findBy — returns a promise, waits for element to appear (use for async content)
const successMessage = await screen.findByText(/account created/i);
Use getBy for elements that must be present, queryBy for elements that should be absent, and findBy for elements that appear after async operations.
Simulating User Interactions
React Testing Library’s companion package @testing-library/user-event simulates realistic user interactions including typing, clicking, tabbing, and selecting options.
// src/components/SearchBar.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SearchBar } from './SearchBar';
test('should call onSearch when user types and presses enter', async () => {
const user = userEvent.setup();
const onSearch = jest.fn();
render(<SearchBar onSearch={onSearch} />);
const input = screen.getByRole('searchbox');
// Type into the search input
await user.type(input, 'react hooks');
// Verify the input value updated
expect(input).toHaveValue('react hooks');
// Press Enter to submit
await user.keyboard('{Enter}');
// Verify the callback was called with the search term
expect(onSearch).toHaveBeenCalledWith('react hooks');
});
test('should clear input when clear button is clicked', async () => {
const user = userEvent.setup();
render(<SearchBar onSearch={jest.fn()} />);
const input = screen.getByRole('searchbox');
await user.type(input, 'some query');
// Click the clear button
await user.click(screen.getByRole('button', { name: /clear/i }));
expect(input).toHaveValue('');
});
Always use userEvent.setup() at the start of each test and call interactions with await. The userEvent library fires all the intermediate events a real browser would — pointerDown, mouseDown, focus, pointerUp, mouseUp, click — which catches event handler bugs that fireEvent.click() would miss. For a deeper understanding of the React hooks these components rely on, see our React hooks deep dive.
Testing Async Behavior
Components that fetch data, show loading states, or respond to timers require async-aware assertions. React Testing Library provides findBy queries and the waitFor utility for this purpose.
// src/components/UserProfile.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import { UserProfile } from './UserProfile';
// Mock the fetch call
global.fetch = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
test('should show loading state then user data', async () => {
(fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({ name: 'Alice', email: 'alice@example.com' }),
});
render(<UserProfile userId={1} />);
// Initially shows loading
expect(screen.getByText(/loading/i)).toBeInTheDocument();
// Wait for user data to appear
expect(await screen.findByText('Alice')).toBeInTheDocument();
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
// Loading indicator should be gone
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
test('should show error message when fetch fails', async () => {
(fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
render(<UserProfile userId={1} />);
// Wait for error message to appear
expect(
await screen.findByText(/something went wrong/i)
).toBeInTheDocument();
// Verify retry button is available
expect(
screen.getByRole('button', { name: /retry/i })
).toBeInTheDocument();
});
The findByText query automatically waits up to 1 second (configurable) for the element to appear. For more complex async scenarios, use waitFor to wait for an assertion to pass:
test('should update the list after adding an item', async () => {
const user = userEvent.setup();
render(<TodoList />);
await user.type(screen.getByLabelText(/new task/i), 'Buy groceries');
await user.click(screen.getByRole('button', { name: /add/i }));
// Wait for the list to update
await waitFor(() => {
expect(screen.getAllByRole('listitem')).toHaveLength(1);
});
expect(screen.getByText('Buy groceries')).toBeInTheDocument();
});
Testing Forms and Validation
Forms are among the most important components to test because they directly affect user experience and data integrity.
// src/components/RegistrationForm.test.tsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { RegistrationForm } from './RegistrationForm';
test('should show validation errors for empty required fields', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<RegistrationForm onSubmit={onSubmit} />);
// Submit without filling any fields
await user.click(screen.getByRole('button', { name: /register/i }));
// Expect validation errors to appear
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
expect(screen.getByText(/password is required/i)).toBeInTheDocument();
// Form should not have been submitted
expect(onSubmit).not.toHaveBeenCalled();
});
test('should show error for invalid email format', async () => {
const user = userEvent.setup();
render(<RegistrationForm onSubmit={jest.fn()} />);
await user.type(screen.getByLabelText(/email/i), 'not-an-email');
await user.tab(); // Trigger blur validation
expect(
await screen.findByText(/enter a valid email/i)
).toBeInTheDocument();
});
test('should submit form with valid data', async () => {
const user = userEvent.setup();
const onSubmit = jest.fn();
render(<RegistrationForm onSubmit={onSubmit} />);
// Fill in all fields with valid data
await user.type(screen.getByLabelText(/email/i), 'alice@example.com');
await user.type(screen.getByLabelText(/password/i), 'SecurePass123!');
await user.type(
screen.getByLabelText(/confirm password/i),
'SecurePass123!'
);
// Submit the form
await user.click(screen.getByRole('button', { name: /register/i }));
// Verify submit was called with the correct data
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'SecurePass123!',
});
});
});
Notice the tests interact with the form exactly like a user would — typing into labeled inputs, clicking buttons by name, and reading error messages by their text. The tests don’t reference internal state, form library internals, or component methods. If you’re using React Hook Form with Zod, these same patterns apply — see our guide on React Hook Form with Zod validation for the form implementation side.
Testing Components with Context and Providers
Components that rely on React context (theme, auth, router) need wrapper providers in tests. Create a reusable render utility that wraps components with the necessary providers.
// tests/utils/renderWithProviders.tsx
import { render, RenderOptions } from '@testing-library/react';
import { ReactElement } from 'react';
import { BrowserRouter } from 'react-router-dom';
import { ThemeProvider } from '../src/context/ThemeContext';
import { AuthProvider } from '../src/context/AuthContext';
interface CustomRenderOptions extends Omit<RenderOptions, 'wrapper'> {
initialRoute?: string;
}
function AllProviders({ children }: { children: React.ReactNode }) {
return (
<BrowserRouter>
<AuthProvider>
<ThemeProvider>{children}</ThemeProvider>
</AuthProvider>
</BrowserRouter>
);
}
export function renderWithProviders(
ui: ReactElement,
options?: CustomRenderOptions
) {
if (options?.initialRoute) {
window.history.pushState({}, '', options.initialRoute);
}
return render(ui, { wrapper: AllProviders, ...options });
}
// Re-export everything from testing library
export * from '@testing-library/react';
Use this custom render in your tests:
// src/components/Navbar.test.tsx
import { renderWithProviders, screen } from '../../tests/utils/renderWithProviders';
import userEvent from '@testing-library/user-event';
import { Navbar } from './Navbar';
test('should show logout button when user is authenticated', () => {
renderWithProviders(<Navbar />, { initialRoute: '/dashboard' });
expect(
screen.getByRole('button', { name: /log out/i })
).toBeInTheDocument();
});
test('should show login link when user is not authenticated', () => {
renderWithProviders(<Navbar />, { initialRoute: '/' });
expect(
screen.getByRole('link', { name: /sign in/i })
).toBeInTheDocument();
});
This pattern prevents duplicating provider setup across dozens of test files. When you add a new global provider, update it in one place and all tests benefit automatically. For understanding how state management affects component testing, see our guide on state management in React.
Testing Custom Hooks
React Testing Library provides renderHook for testing custom hooks in isolation:
// src/hooks/useDebounce.test.ts
import { renderHook, act } from '@testing-library/react';
import { useDebounce } from './useDebounce';
test('should debounce value changes', async () => {
jest.useFakeTimers();
const { result, rerender } = renderHook(
({ value }) => useDebounce(value, 300),
{ initialProps: { value: 'initial' } }
);
// Initial value should be returned immediately
expect(result.current).toBe('initial');
// Update the value
rerender({ value: 'updated' });
// Should still show old value before debounce delay
expect(result.current).toBe('initial');
// Fast-forward time past the debounce delay
act(() => {
jest.advanceTimersByTime(300);
});
// Now the debounced value should update
expect(result.current).toBe('updated');
jest.useRealTimers();
});
Test hooks directly when they contain meaningful logic. If a hook is just a thin wrapper around useState, testing it through the component that uses it provides more confidence with less effort.
Real-World Scenario: Migrating from Enzyme to React Testing Library
Consider a mid-sized React application with 80 components and a test suite of 200 Enzyme tests. The team is upgrading to React 18, but Enzyme doesn’t support React 18’s concurrent features. Many existing tests directly access component state with wrapper.state(), simulate events with wrapper.find('.btn-class').simulate('click'), and assert on internal props.
Rather than rewriting all 200 tests at once, the team takes an incremental approach. They install React Testing Library alongside Enzyme and establish a rule: all new tests use React Testing Library, and any test that needs modification for a bug fix or feature change gets migrated at the same time.
The biggest adjustment is shifting from CSS selector queries to accessible queries. Tests that used wrapper.find('.submit-button') become screen.getByRole('button', { name: /submit/i }). This migration reveals several accessibility gaps — buttons without labels, form inputs without associated <label> elements, and headings with incorrect hierarchy. Fixing these issues improves accessibility for real users while making the tests more resilient.
After two months, roughly 60% of the test suite runs on React Testing Library. The migrated tests break less often during refactors because they don’t depend on CSS class names or component structure. The key trade-off is the initial learning curve — developers need to think about components from the user’s perspective rather than the implementation perspective. However, once the pattern clicks, tests become faster to write and easier to understand because they describe user behavior, not internal mechanics.
When to Use React Testing Library
- You’re testing React components and want tests that verify user-visible behavior rather than implementation details
- Your component handles user interactions like clicks, typing, form submissions, or navigation
- You need to test async behavior like data fetching, loading states, and error handling
- You want tests that survive refactors — changing internal state management from useState to useReducer shouldn’t break tests
- You need to verify accessibility by testing with the same queries assistive technologies use
When NOT to Use React Testing Library
- You need to test complex business logic that lives outside components — extract it into plain functions and test with Jest directly
- You’re testing visual appearance like specific colors, spacing, or animations — use visual regression testing tools like Percy or Chromatic instead
- You need to test performance characteristics like render counts or memoization — use React DevTools Profiler or dedicated performance tests
- You’re testing a hook with complex logic that’s easier to verify in isolation — use
renderHookrather than testing through a component
Common Mistakes with React Testing Library
- Using
getByTestIdas the default query. Test IDs bypass accessibility verification entirely. UsegetByRole,getByLabelText, orgetByTextfirst. Only fall back togetByTestIdfor elements that genuinely have no accessible name or role. - Using
fireEventinstead ofuserEvent. ThefireEventutility dispatches a single DOM event. Real user interactions involve multiple events (mousedown, focus, mouseup, click). UseuserEventfor realistic simulation that catches event handling bugs. - Wrapping everything in
act(). React Testing Library handlesact()internally for its own utilities. Manualact()wrapping is only needed for state updates triggered outside of RTL methods, like timers. Unnecessaryact()calls add noise and hide real async issues. - Testing implementation details through container queries. Using
container.querySelector('.my-class')defeats the purpose of React Testing Library. If you catch yourself querying by CSS class or DOM structure, refactor to use accessible queries instead. - Not waiting for async updates. Asserting immediately after an action that triggers a state update or API call leads to flaky tests. Use
findByqueries orwaitForfor any assertion that depends on async behavior. - Creating separate test files for every tiny component. Simple presentational components that just render props don’t need their own test files. Test them through the parent component that uses them — this tests the integration and reduces test file proliferation.
Conclusion
React Testing Library shifts your testing mindset from “does this component have the right internal state?” to “does this component work correctly for the user?” Query elements by their accessible roles and labels, simulate interactions with userEvent, and assert on what appears on screen. This approach produces tests that catch real bugs, survive refactors, and double as accessibility verification.
Start by writing tests for your most complex interactive components — forms, modals, and data-fetching views — where user behavior is most nuanced. For your next step, explore our guide on React performance optimization to understand how memoization and rendering patterns affect the components you test.