React Testing Library encourages testing components the way users interact with them — by finding elements through accessible roles, text, and labels rather than implementation details like class names or component state.
Setup
Install the required packages:
npm install -D @testing-library/react @testing-library/jest-dom @testing-library/user-eventAdd the custom matchers to your Jest setup file:
// jest.setup.ts
import '@testing-library/jest-dom';// jest.config.js
module.exports = {
setupFilesAfterSetup: ['./jest.setup.ts'],
testEnvironment: 'jsdom',
};The @testing-library/jest-dom package adds matchers like toBeInTheDocument(), toHaveTextContent(), and toBeDisabled().
Rendering and Querying
The render function mounts a component into a virtual DOM. The screen object provides query methods to find elements:
import { render, screen } from '@testing-library/react';
function Greeting({ name }: { name: string }) {
return <h1>Hello, {name}!</h1>;
}
test('renders a personalized greeting', () => {
render(<Greeting name="Alice" />);
expect(screen.getByRole('heading')).toHaveTextContent('Hello, Alice!');
});
Query Types: getBy vs queryBy vs findBy
The query prefix determines behavior when an element is not found:
| Prefix | Not found | Async? | Use case |
|---|---|---|---|
getBy | Throws error | No | Element should exist right now |
queryBy | Returns null | No | Assert element does NOT exist |
findBy | Throws after timeout | Yes | Element appears after async operation |
// Assert element exists
expect(screen.getByText('Submit')).toBeInTheDocument();
// Assert element does NOT exist
expect(screen.queryByText('Error')).not.toBeInTheDocument();
// Wait for element to appear (async)
const message = await screen.findByText('Saved successfully');
expect(message).toBeInTheDocument();Testing User Interactions
Use @testing-library/user-event to simulate realistic user interactions:
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
function Counter() {
const [count, setCount] = React.useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={()=> setCount(c=> c + 1)}>Increment</button>
<button onClick={()=> setCount(0)}>Reset</button>
</div>
);
}
test('increments count on button click', async () => {
const user = userEvent.setup();
render(<Counter />);
expect(screen.getByText('Count: 0')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Increment' }));
expect(screen.getByText('Count: 1')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Increment' }));
expect(screen.getByText('Count: 2')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Reset' }));
expect(screen.getByText('Count: 0')).toBeInTheDocument();
});
Testing Form Input
function LoginForm({ onSubmit }: { onSubmit: (data: { email: string; password: string }) => void }) {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
onSubmit({
email: form.get('email') as string,
password: form.get('password') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" />
<label htmlFor="password">Password</label>
<input id="password" name="password" type="password" />
<button type="submit">Sign In</button>
</form>
);
}
test('submits email and password', async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<LoginForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText('Email'), 'alice@example.com');
await user.type(screen.getByLabelText('Password'), 'secret123');
await user.click(screen.getByRole('button', { name: 'Sign In' }));
expect(handleSubmit).toHaveBeenCalledWith({
email: 'alice@example.com',
password: 'secret123',
});
});
Testing Async Behavior
Components that fetch data or wait for timers need async queries:
function UserProfile({ userId }: { userId: number }) {
const [user, setUser] = React.useState<{ name: string } | null>(null);
React.useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setUser);
}, [userId]);
if (!user) return <p>Loading...</p>;
return <h2>{user.name}</h2>;
}
test('shows user name after loading', async () => {
global.fetch = jest.fn().mockResolvedValue({
json: async () => ({ name: 'Alice' }),
});
render(<UserProfile userId={1} />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
const heading = await screen.findByRole('heading');
expect(heading).toHaveTextContent('Alice');
});
The findByRole query waits up to 1 second (configurable) for the element to appear in the DOM.
Key Takeaways
- Use
screen.getByRoleandscreen.getByLabelTextto find elements like a user would. - Use
getByfor elements that exist,queryByto assert absence,findByfor async elements. - Use
userEventoverfireEventfor realistic interaction simulation. - Mock
fetchor API layers — not React internals or component state. - Test behavior (what the user sees and does), not implementation (state values, effect hooks).