React Testing with Jest and RTL
Testing ensures your React components work correctly and continue working as the codebase changes. Jest is the test runner and assertion library. React Testing Library (RTL) provides utilities to render components and query the output the way a real user would see it — by text, role, and label rather than internal implementation details.
Why Test React Components
Tests catch bugs before they reach users. They also give you confidence to refactor code — you can restructure a component's internals knowing the tests will tell you if something breaks. The most valuable tests check what the user sees and does, not how the code is structured internally.
Installing Testing Tools
Vite projects need these packages. Create React App includes them by default.
npm install --save-dev @testing-library/react @testing-library/user-event @testing-library/jest-dom vitest jsdom
RTL's Philosophy: Test Like a User
Diagram: Implementation vs user-facing testing
BAD — Tests internal implementation:
Find element by internal ID or class name
Check component's internal state directly
Call component methods directly
(Brittle: breaks when you refactor)
GOOD — Tests what the user sees:
Find element by its visible text or ARIA role
Check what appears on screen after interaction
Simulate real user actions (click, type, submit)
(Robust: works even after code refactoring)
Writing Your First Test
// Greeting.jsx
function Greeting({ name }) {
return <h1>Hello, {name}!</h1>;
}
export default Greeting;
// Greeting.test.jsx
import { render, screen } from "@testing-library/react";
import Greeting from "./Greeting";
test("renders a greeting with the user's name", () => {
render(<Greeting name="Alice" />);
const heading = screen.getByText("Hello, Alice!");
expect(heading).toBeInTheDocument();
});
Querying Elements: getBy, queryBy, findBy
// getBy — throws error if not found (use when element SHOULD exist)
screen.getByText("Submit")
screen.getByRole("button", { name: "Submit" })
screen.getByLabelText("Email address")
screen.getByPlaceholderText("Enter your name")
screen.getByTestId("cart-icon")
// queryBy — returns null if not found (use to assert element is ABSENT)
expect(screen.queryByText("Error message")).not.toBeInTheDocument();
// findBy — returns a Promise, for elements that appear asynchronously
const alert = await screen.findByText("Saved successfully");
expect(alert).toBeInTheDocument();
Simulating User Interactions
// Counter.jsx
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
// Counter.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import Counter from "./Counter";
test("increments count when button is clicked", 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();
});
test("resets count when Reset is clicked", async () => {
const user = userEvent.setup();
render(<Counter />);
await user.click(screen.getByRole("button", { name: "Increment" }));
await user.click(screen.getByRole("button", { name: "Increment" }));
await user.click(screen.getByRole("button", { name: "Reset" }));
expect(screen.getByText("Count: 0")).toBeInTheDocument();
});
Testing Forms
// LoginForm.test.jsx
test("submits form with 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: "Login" }));
expect(handleSubmit).toHaveBeenCalledWith({
email: "alice@example.com",
password: "secret123",
});
});
test("shows validation error for invalid email", async () => {
const user = userEvent.setup();
render(<LoginForm />);
await user.type(screen.getByLabelText("Email"), "notanemail");
await user.click(screen.getByRole("button", { name: "Login" }));
expect(screen.getByText("Enter a valid email address.")).toBeInTheDocument();
});
Testing Async Data Loading
// Mock the fetch call
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve([{ id: 1, name: "Alice" }]),
})
);
test("shows user list after data loads", async () => {
render(<UserList />);
expect(screen.getByText("Loading...")).toBeInTheDocument();
// Wait for async data to appear
const user = await screen.findByText("Alice");
expect(user).toBeInTheDocument();
expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
});
Common Jest Matchers
expect(element).toBeInTheDocument() // Element exists in DOM
expect(element).not.toBeInTheDocument() // Element does not exist
expect(element).toBeVisible() // Element is visible
expect(element).toBeDisabled() // Button/input is disabled
expect(element).toHaveValue("Alice") // Input has this value
expect(element).toHaveTextContent("OK") // Element contains text
expect(fn).toHaveBeenCalled() // Function was called
expect(fn).toHaveBeenCalledWith(args) // Function called with specific args
expect(fn).toHaveBeenCalledTimes(2) // Function called exactly twice
Running Tests
# Run all tests once
npm test
# Run tests in watch mode (re-runs on file save)
npm test -- --watch
# Run tests with coverage report
npm test -- --coverage
What to Test and What to Skip
Test: Skip:
-------------------------------------------
Rendered output for given props CSS class names
User interactions (click, type) Internal state values
Conditional rendering Implementation details
Form validation messages Third-party library internals
API loading/error/success states Trivial components (just renders text)
Summary
Jest provides the test runner, assertion functions, and mock utilities. React Testing Library renders components and queries them the way a user would — by visible text, ARIA roles, and labels. Use getBy queries when an element should definitely be present, queryBy to assert absence, and findBy for elements that appear asynchronously. Simulate user interactions with userEvent from @testing-library/user-event — it more closely mimics real browser events than the older fireEvent. Test what users see and what functions get called, not how your component is internally structured. Write tests for every piece of user-facing logic: form validation, loading states, conditional rendering, and interaction outcomes.
