At some point, every React developer ends up with a component that "works in the browser" but has no tests. And it stays that way for months, because writing tests for React components used to feel like more trouble than it was worth.
I have been there. I had projects full of components tested manually, refreshed in the browser a hundred times, and shipped with fingers crossed. It was fine until it was not.
This article is about the testing setup that actually clicked for me: React Testing Library paired with Jest, writing tests that focus on behavior rather than implementation details.
Why React Testing Library?
Before RTL, Enzyme was the standard. Enzyme lets you inspect internal state, call lifecycle methods directly, and assert on component internals. It sounds powerful, and it is. But it also means your tests break every time you refactor, even when the component still does the same thing for the user.
React Testing Library flips the model. You query elements the same way a user would: by label text, by role, by visible text. You fire events the way a user would. You assert on what the user sees. Refactor the internals, the tests still pass. That is the goal.
The Setup
If you are using Create React App or Vite with the official React plugin, RTL and Jest are either already included or trivial to add.
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-eventAdd this to your Jest setup file (or create one):
import '@testing-library/jest-dom';That imports the custom matchers like toBeInTheDocument(), toHaveValue(), and toBeDisabled(), which make assertions readable.
Writing Your First Test
Start with a simple example. A login form with two fields and a submit button.
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import LoginForm from './LoginForm';
test('shows error when submitted with empty fields', async () => {
render(<LoginForm />);
await userEvent.click(screen.getByRole('button', { name: /submit/i }));
expect(screen.getByText(/email is required/i)).toBeInTheDocument();
});Notice what this test does not do: it does not check component state, does not call any internal function, and does not care how the component is structured. It renders the component, acts like a user, and checks what appears on screen.
Querying Elements the Right Way
RTL gives you several query methods, and the order of preference matters.
- getByRole is the first choice. Buttons, links, inputs, and headings all have roles. Use this whenever you can.
- getByLabelText is ideal for form inputs associated with a label element.
- getByText is useful for paragraphs, headings, or any visible text content.
- getByTestId is the last resort. If nothing else works, add a
data-testidattribute. But treat it as a signal that your component might not be accessible enough.
Using roles and labels has a side effect: it nudges your components toward better accessibility. If you cannot query a button by its name, maybe that button needs an aria-label.
Async Interactions
Real components fetch data, delay rendering, and update asynchronously. RTL handles this with findBy queries, which return a Promise and retry until the element appears or a timeout is hit.
test('loads and displays user data', async () => {
render(<UserProfile userId="42" />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
const name = await screen.findByText(/paulo silva/i);
expect(name).toBeInTheDocument();
});Pair this with userEvent from @testing-library/user-event instead of fireEvent. The userEvent API simulates real browser behavior more closely: typing triggers keydown, keypress, and keyup. Clicking checks that the element is actually clickable. It is slower but more accurate.
Mocking External Dependencies
Most components depend on something external: an API call, a custom hook, a context. The simplest approach is to mock the module that makes the call.
jest.mock('../api/users', () => ({
fetchUser: jest.fn().mockResolvedValue({ name: 'Paulo Silva', role: 'engineer' }),
}));For context, wrap your component in the provider inside the test. You can create a custom render helper that wraps with common providers such as auth, theme, and router, so every test file does not repeat the same boilerplate.
function renderWithProviders(ui) {
return render(
<AuthProvider>
<ThemeProvider>
{ui}
</ThemeProvider>
</AuthProvider>
);
}What to Test and What to Skip
Not every component needs a test. Stateless presentational components with no logic are low value. A component that just renders a heading with a prop is covered by TypeScript types and a quick visual check.
Focus your tests on components that have conditional rendering based on state or props, handle user interactions like forms, buttons, and modals, fetch or mutate data, or are used in many places across the codebase.
One integration test covering a full user flow is worth more than ten shallow unit tests asserting that a prop is passed down correctly.
Snapshot Tests: Handle With Care
RTL works alongside Jest snapshot tests, but I use snapshots sparingly. They are easy to write and easy to break for no good reason. A snapshot of a complex component becomes a maintenance burden fast.
If you do use them, keep them small. Snapshot a single rendered element, not an entire page. And review snapshot diffs carefully before blindly updating them.
A Mindset Shift
The thing that made testing click for me was stopping to think about implementation and starting to think about behavior. What does this component do? What does the user see and interact with? Write the test from that angle.
When a test breaks because you renamed an internal state variable, that is a bad test. When a test breaks because the form no longer validates correctly, that is a good test doing its job.
Start with your most critical components. Write one test. Then another. The habit forms faster than you think, and the confidence it gives you when refactoring is hard to overstate.



