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.

Leave a Comment

Your email address will not be published. Required fields are marked *