Blog

Unit Testing React Components

Key Takeaways

  • Test what users can see and do, not a component's internal state or implementation.
  • Jest and Vitest run tests; React Testing Library renders components and helps you query their DOM output.
  • Prefer accessible queries and awaited user interactions. Choose getBy, queryBy, or findBy according to when an element should exist.
  • Test loading, success, and failure states with controlled dependencies, then cover browser-specific behavior separately.

To unit test a React component, render it, interact with it as a user would, and assert the resulting DOM behavior. React Testing Library provides those rendering and query utilities; you pair it with a runner such as Jest or Vitest. This behavior-first approach keeps tests focused on functionality rather than implementation details.

This guide uses Vitest for a complete working setup, then explains what changes in an existing Jest project. If components are still new to you, start with our React components introduction.

Jest vs React Testing Library

These tools aren't alternatives. A runner discovers and executes tests; a rendering library puts your component into a DOM so you can exercise it. Neither the runner's name nor the use of React Testing Library determines whether a test is a unit test or an integration test.

ToolResponsibilityHow it fits
JestTest execution, assertions, mocks, and lifecycle hooksA runner you can keep in an existing project
VitestTest execution, assertions, mocks, and lifecycle hooksA runner that uses Vite's tooling
React Testing LibraryRendering React components and querying DOM outputWorks alongside either runner
user-eventSimulating user interactionsDrives clicks, typing, and other interactions
jest-domDOM-specific assertionsAdds matchers such as toBeInTheDocument

The official Vitest guide, Jest guide, and jest-dom documentation describe these responsibilities and integrations.

A runner can also test ordinary JavaScript

Save this as src/sum.test.js. Unlike a component test, it needs no rendering or user interaction:

import { describe, expect, test } from "vitest";

function sum(a, b) {
  return a + b;
}

describe("sum", () => {
  test("adds two numbers", () => {
    expect(sum(2, 3)).toBe(5);
  });
});

Set Up React Component Testing with Vitest

Use a supported Node.js release that meets the current Vite and Vitest requirements. Create React App is deprecated; it isn't the starting point for this example. Existing CRA projects can keep their tests while planning a toolchain migration.

Install the testing dependencies

Create a small Vite React project, or skip the first three commands if you already have one. Our React quick start covers the application side.

npm create vite@latest react-component-tests -- --template react
cd react-component-tests
npm install
npm install --save-dev vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom msw

The test packages belong in development dependencies. React Testing Library's installation instructions also include its peer dependency, @testing-library/dom. Commit your package lockfile so the resolved dependency versions travel with the project.

Configure a DOM environment

Replace the generated vite.config.js with:

import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: ["./src/test/setup.js"],
  },
});

Vitest's default environment is Node, not a DOM. The jsdom environment supplies browser-like DOM APIs, while setupFiles runs the matcher setup before each test file. Enabling globals also lets React Testing Library register automatic cleanup through the runner's global afterEach.

Load matchers and add test commands

Create src/test/setup.js:

import "@testing-library/jest-dom/vitest";
import { afterEach, vi } from "vitest";

afterEach(() => {
  vi.clearAllMocks();
  vi.restoreAllMocks();
});

The Vitest-specific import registers DOM matchers with Vitest's assertion library. Clearing mock history and restoring spies are separate operations; neither replaces DOM cleanup.

Merge this fragment into your existing package.json, keeping its other fields and scripts:

{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run"
  }
}

Use npm test during development and npm run test:run for a single, non-watching run.

Using React Testing Library with Jest instead

Keep your existing JSX transformation setup, install jest-environment-jsdom, and configure testEnvironment: "jsdom". Load @testing-library/jest-dom from a file listed in setupFilesAfterEnv; use Jest's test APIs instead of importing them from Vitest. See the Jest configuration reference for the environment and setup options.

Jest provides jest.fn() for mock functions. Module mocking depends on your module system: don't copy a CommonJS jest.mock() recipe into native ESM without checking the Jest ESM mocking guidance.

Advertisement

Basic Example: Test a Text Toggle

Write a semantic component

Create src/ToggleText.jsx. A toggle performs an action, so use a button rather than an anchor without a destination. type="button" also avoids submitting an enclosing form.

import { useState } from "react";

export default function ToggleText() {
  const [showText, setShowText] = useState(true);

  return (
    <>
      {showText && <p>Hello world</p>}
      <button
        type="button"
        onClick={() => setShowText((visible) => !visible)}
      >
        Toggle text
      </button>
    </>
  );
}

Assert visible behavior

Create src/ToggleText.test.jsx:

import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import ToggleText from "./ToggleText.jsx";

test("hides and shows the greeting", async () => {
  const user = userEvent.setup();
  render(<ToggleText />, { reactStrictMode: true });

  const button = screen.getByRole("button", { name: "Toggle text" });
  expect(screen.getByText("Hello world")).toBeInTheDocument();

  await user.click(button);
  expect(screen.queryByText("Hello world")).not.toBeInTheDocument();

  await user.click(button);
  expect(screen.getByText("Hello world")).toBeInTheDocument();
});

The test never reads showText. You can change the state representation without changing the test, provided the behavior stays the same. userEvent.setup() creates an interaction session; await its actions. Unlike low-level fireEvent, user-event simulates interactions that can involve several events and interaction checks. Reserve fireEvent for cases user-event doesn't cover.

Choose Between getBy, queryBy, and findBy

Start with getByRole and an accessible name—the label a user or assistive technology uses to identify a control. For form inputs, label-based queries are another strong choice; our React form example provides a useful next component to test. Use text queries for ordinary copy and data-testid only when semantic queries aren't suitable.

ExpectationQueryNo matching element
It exists nowgetBy*Throws immediately
It should be absent nowqueryBy*Returns null
It should appear asynchronouslyawait findBy*Retries, then rejects on timeout

These single-element queries also reject ambiguous matches: multiple matches are an error. Don't use getByText to assert absence—it throws before your negative assertion can run.

Test Asynchronous React Components

Wait for a condition, not an arbitrary delay

Use findBy* when waiting for an element to appear. Use waitFor for another changing condition, such as an existing button becoming enabled; its callback retries when it throws and stops when it succeeds or times out. Returning false doesn't trigger a retry, and waitFor doesn't know whether an arbitrary operation has finished.

Keep actions outside retry callbacks, and don't add sleeps to make a test pass. A retrying callback could otherwise repeat the interaction you're trying to test.

Choose the request-mocking boundary

Component --> service/client --> HTTP request
                 ^                  ^
          mock this function   intercept with MSW

Mocking an Axios module or service function gives you a narrow test of the component's response to that replacement. It doesn't exercise the real client's request construction. Intercepting requests with MSW keeps the component and client code together; treat that broader boundary as a component integration test. Testing Library's async example recommends MSW for API mocking.

Neither approach proves the real backend follows your mocked contract. Choose a service mock for a deliberately narrow test; prefer request-level interception when request behavior is part of the risk you want covered.

Implement loading, success, and failure states

Create src/RemoteToggleText.jsx. This preserves the original behavior: a successful product response reveals “Hello world.” The endpoint is an application route intercepted by the tests below; no backend is needed to run them.

import { useEffect, useRef, useState } from "react";

export default function RemoteToggleText() {
  const [status, setStatus] = useState("idle");
  const pending = useRef(null);

  useEffect(() => () => pending.current?.abort(), []);

  async function loadGreeting() {
    if (pending.current) return;

    const controller = new AbortController();
    pending.current = controller;
    setStatus("loading");

    try {
      const url = new URL("/api/products/1", window.location.href);
      const response = await fetch(url, { signal: controller.signal });
      if (!response.ok) throw new Error("Request failed");

      const data = await response.json();
      if (!data?.id) throw new Error("Missing product id");

      if (!controller.signal.aborted) setStatus("success");
    } catch {
      if (!controller.signal.aborted) setStatus("error");
    } finally {
      if (pending.current === controller) pending.current = null;
    }
  }

  return (
    <>
      <button
        type="button"
        disabled={status === "loading"}
        onClick={loadGreeting}
      >
        Load greeting
      </button>
      {status === "loading" && <p role="status">Loading…</p>}
      {status === "success" && <p>Hello world</p>}
      {status === "error" && (
        <p role="alert">Could not load greeting. Try again.</p>
      )}
    </>
  );
}

Check response.ok because fetch doesn't reject merely because an HTTP response has an error status. The cleanup aborts pending work on unmount, and aborted requests don't become user-facing errors. See MDN's fetch guidance for status handling and cancellation.

Write a complete request-level test suite

Create src/RemoteToggleText.test.jsx. The first test holds the response until loading assertions finish, so a fast machine can't skip the intermediate state. The second verifies failure and a subsequent successful retry.

import {
  afterAll, afterEach, beforeAll, describe, expect, test,
} from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw/http";
import { setupServer } from "msw/node";
import RemoteToggleText from "./RemoteToggleText.jsx";

const endpoint = new URL("/api/products/1", window.location.href).href;
const success = () => HttpResponse.json({ id: 1 });
const server = setupServer(http.get(endpoint, success));

beforeAll(() => server.listen({ onUnhandledFrame: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

describe("RemoteToggleText", () => {
  test("shows loading, then the greeting", async () => {
    let release;
    const responseReady = new Promise((resolve) => {
      release = resolve;
    });

    server.use(http.get(endpoint, async () => {
      await responseReady;
      return success();
    }));

    const user = userEvent.setup();
    render(<RemoteToggleText />, { reactStrictMode: true });
    const button = screen.getByRole("button", { name: "Load greeting" });
    expect(screen.queryByText("Hello world")).not.toBeInTheDocument();

    try {
      await user.click(button);
      expect(screen.getByRole("status")).toHaveTextContent("Loading");
      expect(button).toBeDisabled();
    } finally {
      release();
    }

    expect(await screen.findByText("Hello world")).toBeInTheDocument();
    expect(screen.queryByRole("status")).not.toBeInTheDocument();
    expect(button).toBeEnabled();
  });

  test("shows an error and lets the user retry", async () => {
    server.use(http.get(endpoint, () =>
      new HttpResponse(null, { status: 500 })
    ));

    const user = userEvent.setup();
    render(<RemoteToggleText />, { reactStrictMode: true });
    const button = screen.getByRole("button", { name: "Load greeting" });

    await user.click(button);
    expect(await screen.findByRole("alert"))
      .toHaveTextContent("Could not load greeting. Try again.");
    expect(screen.queryByText("Hello world")).not.toBeInTheDocument();
    expect(button).toBeEnabled();

    server.use(http.get(endpoint, success));
    await user.click(button);

    expect(await screen.findByText("Hello world")).toBeInTheDocument();
    expect(screen.queryByRole("alert")).not.toBeInTheDocument();
  });
});

The imports and lifecycle follow the current MSW quick start. Per-test handlers override the baseline and are removed afterward. The onUnhandledFrame error strategy rejects unexpected network traffic; check installed-package documentation before mixing this setup with older MSW examples.

Advertisement

Best Practices for React Unit Testing

Keep setup explicit and tests isolated

Use describe to group a behavior, not to build a deeply nested hierarchy. Use beforeEach for fresh fixtures when useful, but render inside each test or a small helper called by that test. Shared rendering can hide which props, providers, or initial state a scenario depends on.

DOM cleanup unmounts rendered React trees; it doesn't reset every external resource. Reset MSW overrides, restore spies, and reset shared mock implementations when necessary. In Vitest, clearAllMocks clears call history, resetAllMocks also resets implementations, and restoreAllMocks restores methods replaced by spies.

Test with modern React rendering behavior

React Testing Library uses createRoot-based rendering by default for modern React. Its reactStrictMode option defaults to false; these examples enable it explicitly. Strict Mode adds development-only checks, including extra renders and Effect setup/cleanup, so don't assert a universal “runs once” rule for Effects.

If an act warning appears, first check for an unawaited interaction or missing async assertion. React Testing Library's helpers are wrapped with act; manually triggered updates outside those helpers may need explicit handling. Don't suppress the warning instead of fixing the test.

Cover risks beyond the happy path

  • Check that pending requests disable repeated actions and that a retry produces the correct visible result.
  • Add cases for malformed responses, network failures, and reloading after a previous success.
  • Exercise unmounting during a pending request when leaving the screen is an important flow; verify cancellation at the request boundary rather than inspecting component state.
  • Keep browser-level tests for layout, navigation, browser APIs, and complete application journeys.

A jsdom test isn't a real-browser test: jsdom doesn't perform visual layout or implement full navigation. Component tests therefore complement, rather than replace, browser-level end-to-end coverage.

FAQ

Do I need Jest to use React Testing Library?

No. React Testing Library provides rendering and DOM-query utilities, not a test runner; you can pair it with Jest or Vitest. Configure the runner's DOM environment and assertion matchers separately.

How do I configure React Testing Library with Vitest?

Install the Testing Library packages and jsdom, set the test environment to jsdom, and load @testing-library/jest-dom/vitest through setupFiles. Enable Vitest globals for automatic React Testing Library cleanup, or register cleanup explicitly after each test.

When should I use getBy, queryBy, and findBy queries?

Use getBy when an element should exist immediately, queryBy to assert that it is absent, and await findBy when it should appear asynchronously. Single-element queries report an error when multiple elements match.

Should I use userEvent or fireEvent in React Testing Library?

Prefer user-event for user actions because it simulates interactions rather than dispatching only an individual event. Create a session with userEvent.setup(), await its actions, and use fireEvent for specific events that user-event doesn't cover.

How do I test asynchronous React components?

Control the dependency, trigger the interaction, and await the user-visible outcome with a findBy query. Use waitFor for other changing conditions; its callback must throw to trigger a retry, and it isn't a generic signal that all asynchronous work has completed.

Further Reading

Advertisement

Written by Sam Erickson

Pen name of a software engineer with 20+ years in the industry, from Fortune 500 companies to startups. I write every article on StackChief myself. Author of Mastering the Tech Interview. More about me

Advertisement