React Testing Library
The React ecosystem has shifted decisively from testing implementation details (checking internal state or method calls) to testing behavior (checking what the user sees and interacts with). React Testing Library (RTL) is the standard bearer for this philosophy.
[!TIP] Guiding Principle: “The more your tests resemble the way your software is used, the more confidence they can give you.” — Kent C. Dodds
1. The Testing Trophy
Unlike the traditional “Testing Pyramid” which emphasizes unit tests, modern frontend development favors the Testing Trophy:
- Static Analysis (ESLint, TypeScript): Catch typos and type errors cheaply.
- Unit Tests (Jest/Vitest): Verify individual functions or isolated components.
- Integration Tests (RTL): Verify how components work together. (The Sweet Spot).
- E2E Tests (Playwright/Cypress): Verify critical user flows.
RTL shines in the Integration layer. It mounts your component tree and lets you interact with it like a user would.
2. Query Priority: Finding Elements Like a User
One of the most common mistakes is using the wrong query to find elements. RTL provides a strict hierarchy of queries based on accessibility and resilience.
The Hierarchy of Queries
getByRole(Top Priority): Queries elements exposed in the accessibility tree (Buttons, Headings, Textboxes). This enforces semantic HTML.- Example:
screen.getByRole('button', { name: /submit/i })
- Example:
getByLabelText: Great for form fields. Connects the label to the input.- Example:
screen.getByLabelText(/email address/i)
- Example:
getByPlaceholderText: Useful if no label exists (but you should probably add a label).getByText: For non-interactive elements like divs, spans, or paragraphs.getByDisplayValue: Useful for finding pre-filled form fields.getByTestId: The last resort. Use only if you cannot select by role or text.- Example:
screen.getByTestId('custom-complex-widget')
- Example:
Interactive: RTL Query Workbench
Test your knowledge. Write a query to select the element highlighted in green in the DOM Preview.
DOM Preview (Rendered)
Login
Your Test
[!WARNING] Avoid Implementation Details: Never use
container.querySelector('.my-class'). If you change the CSS class name, your test breaks, even if the app still works for the user.
getBy vs findBy vs queryBy
| Prefix | Behavior | Async? | Use Case |
|---|---|---|---|
getBy |
Throws error if not found | No | Standard assertions (element must be there) |
queryBy |
Returns null if not found |
No | Asserting absence (element must not be there) |
findBy |
Returns Promise (waits up to 1000ms) | Yes | Elements appearing later (after API call) |
3. User Events: Simulating Real Interaction
In the early days, we used fireEvent.click(button). This simply dispatches a DOM event. However, real browser interactions are complex. A click involves hover → mousedown → focus → mouseup → click.
Enter user-event:
// ❌ Old Way (Artificial)
fireEvent.change(input, { target: { value: 'hello' } });
// ✅ New Way (Realistic)
await userEvent.type(input, 'hello');
userEvent simulates the full browser event lifecycle, catching bugs that fireEvent might miss (e.g., typing into a disabled input or one with pointer-events: none).
4. Advanced Testing Patterns
1. Testing Custom Hooks
You cannot call a hook outside of a component. To test hooks in isolation, use renderHook.
// useCounter.js
export const useCounter = () => {
const [count, setCount] = useState(0);
const increment = () => setCount(c => c + 1);
return { count, increment };
};
// useCounter.test.js
import { renderHook, act } from '@testing-library/react';
import { useCounter } from './useCounter';
test('should increment counter', () => {
const { result } = renderHook(() => useCounter());
expect(result.current.count).toBe(0);
// Updates must be wrapped in act()
act(() => {
result.current.increment();
});
expect(result.current.count).toBe(1);
});
2. Accessibility Testing with jest-axe
RTL encourages accessible code, but it doesn’t catch everything (like low contrast). Integrate jest-axe for automated a11y checks.
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('form should be accessible', async () => {
const { container } = render(<MyForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
3. Testing Context Providers
When testing components that rely on Context (like Redux, Theme, or Auth), you must wrap them in the provider. A common pattern is to create a custom render function.
// test-utils.js
import { render } from '@testing-library/react';
import { ThemeProvider } from './theme-context';
const customRender = (ui, options) =>
render(ui, { wrapper: ThemeProvider, ...options });
// re-export everything
export * from '@testing-library/react';
export { customRender as render };
Now in your tests, you just import render from test-utils and it automatically wraps your component.
5. Common Pitfalls (Anti-Patterns)
- Wrapping everything in
act(...): RTL wraps state updates inactautomatically for you. If you seeactwarnings, it usually means you forgot toawaitan async operation. - Using
waitForfor side effects:// ❌ WRONG: Don't perform actions inside waitFor await waitFor(() => { userEvent.click(button); }); // ✅ RIGHT: Action first, then wait for result await userEvent.click(button); await waitFor(() => expect(mockFn).toHaveBeenCalled()); - Testing implementation details: Checking
wrapper.state('count')is forbidden in RTL. Check the rendered text instead.