Render, Query, Interact — That's the Whole Library
Testing
React Testing Library tests components from the user's perspective. Three things — render the component, query the DOM, interact like a user.
What you'll learn
- Set up Vitest + React Testing Library
- Render a component and query it
- Simulate user interaction
The dominant testing approach in React is React Testing Library (RTL) — write tests from the user’s perspective, not from implementation details.
Install
For a Vite project, use Vitest as the test runner:
npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom In vite.config.js:
export default {
test: { environment: "jsdom", globals: true, setupFiles: "./vitest.setup.js" },
}; In vitest.setup.js:
import "@testing-library/jest-dom"; The Three Steps
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Counter from "./Counter";
test("increments on click", async () => {
// 1. render
render(<Counter />);
// 2. query
const button = screen.getByRole("button", { name: /count/i });
expect(button).toHaveTextContent("0");
// 3. interact
await userEvent.click(button);
expect(button).toHaveTextContent("1");
}); Querying
Always prefer role-based queries — they’re how assistive tech finds elements:
| Query | Finds |
|---|---|
getByRole("button", { name }) | <button> by its visible text/label |
getByRole("textbox", { name }) | <input> by its label |
getByLabelText("Email") | An input by its <label> |
getByText("Sign up") | Any element with that text |
getByTestId("save") | Last resort — uses data-testid |
getBy* throws if not found, queryBy* returns null, findBy*
waits async.
Interactions With user-event
const user = userEvent.setup();
await user.click(button);
await user.type(input, "hello");
await user.tab();
await user.keyboard("{Enter}"); userEvent simulates real user actions (typing fires multiple
events, etc.) better than the older fireEvent.
Async Updates
For things that happen after a fetch or timer, use findBy*:
test("loads users", async () => {
render(<UserList />);
expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
}); findBy* retries for ~1 second by default — great fit for async
UIs.
What NOT To Test
- Implementation details (props, state, hook internals)
- Whether
useStatewas called (it doesn’t matter) - Specific class names — query by what the user sees
If your test breaks when you refactor the internals but the user experience is unchanged, the test is too tightly coupled.
What TO Test
- “Clicking the button increments the counter”
- “Submitting the form sends the right data”
- “If the API errors, the user sees an error message”
Tests that read like a description of the feature → tests that survive refactors.
Up Next
Accessibility — make sure everyone can use what you built.
Accessibility →