react testing library
DOM-focused testing utilities for React components
$ npx docs2skills add react-testing-libraryReact Testing Library
DOM-focused testing utilities for React components
What this skill does
React Testing Library provides lightweight utilities for testing React components by interacting with actual DOM nodes rather than component instances. It encourages testing components the way users interact with them - finding elements by their text content, labels, and roles rather than implementation details like component state or props.
The library's core philosophy is "the more your tests resemble the way your software is used, the more confidence they can give you." This approach makes tests more maintainable since refactoring component implementation won't break tests as long as user-facing behavior remains the same. It also naturally promotes accessibility by encouraging developers to use proper semantic HTML and ARIA attributes.
React Testing Library is built on top of DOM Testing Library and serves as a lightweight alternative to Enzyme, focusing specifically on user-centric testing patterns while discouraging testing of implementation details.
Prerequisites
- React 16.8+ (hooks support)
- Node.js 14+
- A test runner (Jest recommended)
- Peer dependencies:
@testing-library/dom - TypeScript users need:
@types/react,@types/react-dom
Quick start
npm install --save-dev @testing-library/react @testing-library/dom
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import Button from './Button';
test('calls onClick prop when clicked', async () => {
const handleClick = jest.fn();
const user = userEvent.setup();
render(<Button onClick={handleClick}>Click me</Button>);
await user.click(screen.getByRole('button', { name: /click me/i }));
expect(handleClick).toHaveBeenCalledTimes(1);
});
Core concepts
DOM-based testing: Tests interact with actual DOM nodes, not React component instances. Use container.querySelector or RTL queries to find elements.
User-centric queries: Find elements the same way users do - by text content, labels, roles, and placeholder text rather than CSS classes or component props.
Query priority: Prefer queries that mirror user experience: getByRole > getByLabelText > getByDisplayValue > getByTestId (escape hatch).
Async utilities: Use waitFor, findBy* queries, and userEvent for testing asynchronous behavior and user interactions.
Cleanup: RTL automatically cleans up DOM after each test, but manual cleanup may be needed for timers, subscriptions, or global state.
Key API surface
// Rendering
import { render, screen } from '@testing-library/react';
const { container, getByText } = render(<Component />);
// Queries (get*, query*, find*)
screen.getByRole('button', { name: /submit/i })
screen.getByLabelText(/email address/i)
screen.getByText(/hello world/i)
screen.getByDisplayValue('current value')
screen.getByTestId('custom-element')
// Async queries
await screen.findByText(/async content/i)
await waitFor(() => expect(screen.getByText('loaded')).toBeInTheDocument())
// User interactions
import userEvent from '@testing-library/user-event';
const user = userEvent.setup();
await user.click(element)
await user.type(input, 'text')
await user.selectOptions(select, ['option1'])
// Assertions (jest-dom)
expect(element).toBeInTheDocument()
expect(element).toHaveTextContent('text')
expect(input).toHaveValue('value')
Common patterns
Form testing:
test('submits form with user input', async () => {
const onSubmit = jest.fn();
const user = userEvent.setup();
render(<ContactForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText(/name/i), 'John Doe');
await user.type(screen.getByLabelText(/email/i), 'john@example.com');
await user.click(screen.getByRole('button', { name: /submit/i }));
expect(onSubmit).toHaveBeenCalledWith({
name: 'John Doe',
email: 'john@example.com'
});
});
Async data loading:
test('displays user data after loading', async () => {
render(<UserProfile userId="123" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
const userName = await screen.findByText(/john doe/i);
expect(userName).toBeInTheDocument();
expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});
Conditional rendering:
test('shows error message on invalid input', async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText(/email/i), 'invalid-email');
await user.click(screen.getByRole('button', { name: /login/i }));
expect(await screen.findByText(/invalid email/i)).toBeInTheDocument();
});
Testing custom hooks:
import { renderHook, act } from '@testing-library/react';
test('useCounter increments count', () => {
const { result } = renderHook(() => useCounter());
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
Configuration
Setup file (src/setupTests.js):
import '@testing-library/jest-dom';
// Global test configuration
import { configure } from '@testing-library/react';
configure({ testIdAttribute: 'data-cy' });
Custom render function:
import { render } from '@testing-library/react';
import { ThemeProvider } from './ThemeProvider';
const customRender = (ui, options) =>
render(ui, { wrapper: ThemeProvider, ...options });
export * from '@testing-library/react';
export { customRender as render };
Best practices
- Prefer getByRole: Most accessible and user-like way to find elements
- Use realistic user interactions: Always use
userEventoverfireEventfor user actions - Test behavior, not implementation: Don't test state, props, or component methods directly
- Use semantic HTML: Proper elements and ARIA attributes make tests easier to write
- Avoid container.querySelector: Use RTL queries instead of manual DOM traversal
- Keep tests focused: One behavior per test, clear arrange/act/assert structure
- Use custom render for providers: Wrap components in necessary context providers
- Wait for async changes: Use
waitFor,findBy*, oractfor async updates - Clean up side effects: Clear timers, subscriptions, and global state between tests
- Use descriptive test names: Name tests after the behavior being verified
Gotchas and common mistakes
Query timing: getBy* queries fail immediately if element not found. Use findBy* for elements that appear asynchronously, queryBy* to assert non-existence.
userEvent vs fireEvent: Always prefer userEvent - it more closely simulates real user interactions and handles edge cases better. Must be awaited: await user.click().
act() warnings: Wrap state updates in act() when testing custom hooks or when updates happen outside RTL utilities. RTL queries handle this automatically.
Cleanup issues: Tests may leak memory or affect other tests if timers, subscriptions, or global state isn't properly cleaned up. Use beforeEach/afterEach for manual cleanup.
Screen vs render destructuring: screen queries work anywhere after render, destructured queries only work in their scope. Prefer screen for cleaner tests.
Case sensitivity in queries: Text queries are case-sensitive by default. Use regex with i flag or exact: false option for case-insensitive matching.
Multiple elements: getBy* queries fail when multiple elements match. Use getAllBy* for arrays or make queries more specific.
Accessibility requirements: getByRole requires proper semantic HTML. Missing roles, labels, or ARIA attributes will cause queries to fail, encouraging better accessibility.
Jest environment: Tests must run in jsdom environment. Configure Jest with testEnvironment: 'jsdom' in package.json or jest.config.js.