World’s largest virtual agentic engineering & quality conference

WHENAUG 19-21
WHEREVirtual · Global
WATCH NOW
Web Development

React Unit Testing Tutorial: How to Test React Components

Learn React unit testing with Vitest and React Testing Library: set up the stack, test components, hooks, async state, mock modules, and run tests in CI.

Author

Harita Ravindranath

Author

Author

Rahul Mishra

Reviewer

Published on: February 5, 2025

Last Updated on: August 11, 2026

You change a prop on a shared component, the app still loads, and three screens quietly break. That is the failure React unit testing is built to catch, and it is why the first test you write should assert on what a user can see rather than on the internals of the component.

This tutorial builds a React unit testing setup that matches the ecosystem as it stands in 2026: Vitest or Jest as the runner, React Testing Library for rendering and queries, and jest-dom for DOM assertions. It covers the install gotcha that breaks most upgrades, testing hooks and async state, mocking modules, and running the suite in CI.

Key Takeaways

React unit testing verifies a single component, hook, or function in isolation, without a browser or a running server. The standard 2026 setup pairs Vitest or Jest as the test runner with React Testing Library, which renders the component and queries it the way a user would, asserting on visible output rather than internal state.

What Does React Unit Testing Actually Cover?

  • Component rendering: Whether the component puts the right text, roles, and attributes in the DOM for a given set of props, including the empty, loading, and error branches that ship untested most often.
  • User interaction: Whether a click, type, or keyboard event produces the state change and callback the component promises, driven through user-event so the sequence matches what a real browser dispatches.
  • Hooks and state: Whether a custom hook returns the right value across updates, tested through renderHook rather than by reaching into component internals.
  • Module mocking: Whether the component behaves correctly when its API layer is replaced by a stub, which is what keeps a unit test fast and independent of the network.

Which Tools Do You Need for React Unit Testing in 2026?

  • Test runner: Vitest 4.1.10 if the app was scaffolded with Vite, since it reuses the same config; Jest 30.4.2 for Next.js or an existing Jest suite.
  • Rendering and queries: @testing-library/react 16.3.2, which requires React 18 or 19 and, since version 16, a separate @testing-library/dom install.
  • DOM matchers: @testing-library/jest-dom 7.0.0 adds assertions such as toBeInTheDocument and toBeDisabled, and ships a dedicated entry point for Vitest.

Where Does React Unit Testing Stop Being Enough?

Unit tests run in jsdom, a JavaScript implementation of the DOM rather than a browser. It has no layout engine, so it cannot tell you that a button is covered by a sticky header, and it cannot reproduce a Safari-only rendering bug. Those need real browser engines, which is what TestMu AI provides across 3,000+ browser and OS combinations.

What Is React Unit Testing?

React unit testing is the practice of rendering one component, hook, or helper in isolation and asserting on its output, with its dependencies replaced by stubs. The test runs in Node against a simulated DOM, so it finishes in milliseconds and needs no server, no build, and no browser.

The word "unit" describes the boundary, not the size. A unit test can render a component that has children, as long as everything outside the boundary you are testing is controlled. Once a test needs a real network call or a real route transition, it has become an integration or integration test and should be judged by different rules.

One idea drives everything below. React Testing Library deliberately gives you no access to component state, props, or instance methods. You query the rendered DOM the way a user finds things, by role and label and text, and you assert on what appears. A test written that way survives a refactor that changes the implementation but keeps the behaviour, which is the only kind of test worth maintaining.

What to Unit Test in a React App, and What to Skip

Test the logic your team wrote and the states a user can reach. Skip anything owned by the framework or the browser.

Worth a unit test:

  • Conditional rendering, and specifically the branches nobody demos: empty list, loading, error, and the zero or one item cases around a plural label.
  • Callbacks a component promises through its props, asserted on the argument passed rather than just on the call count.
  • Custom hooks that hold state or derive values, because they are pure logic with a stable contract.
  • Form validation, including the message shown and whether submit stays disabled while the form is invalid.
  • Anything a bug report has already been filed against, since a regression test is the cheapest test to justify.

Not worth a unit test:

  • Third-party components, which are the vendor's responsibility; test your usage of them, not their behaviour.
  • Styling and layout, which jsdom cannot evaluate because it has no layout engine and reports zero for every measurement.
  • Routing between pages and full user journeys, which belong in React end-to-end testing against a running app.
  • Implementation details such as internal state names or how many times a component re-rendered, since those break on refactors that changed nothing a user sees.

If you need a structure for turning this into written cases before you code, the React test case template lays out the fields to capture.

Set Up the Stack: Vitest, React Testing Library, and jest-dom

Pick the runner by how the app was built. Vitest if you scaffolded with Vite, because it reads the same config and needs no separate transform setup. Jest if you are on Next.js or already have a Jest suite worth keeping. For a wider look at what else is available, see our guide to React testing libraries.

Scaffold a project and install the test packages:

npm create vite@latest react-unit-testing -- --template react
cd react-unit-testing
npm install
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/jest-dom @testing-library/user-event

The @testing-library/dom entry in that command is the one people leave out, and it is the most common reason a fresh install fails. Up to version 15, React Testing Library bundled it as a regular dependency. From version 16 it is a peer dependency, so npm no longer installs it for you and the import fails at run time until you add it yourself. The same release dropped support for React 17 and below, so the current package requires React 18 or 19.

Point Vitest at a jsdom environment and a setup file. Import defineConfig from vitest/config rather than vite, since that is the variant that knows about the test key:

// vite.config.js
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';

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

The setup file registers the jest-dom matchers once for every test file. Note the /vitest entry point, which is what makes a package named after Jest work under Vitest:

// src/setupTests.js
import '@testing-library/jest-dom/vitest';

Finally, add the scripts. The Vite React template ships dev, build, lint, and preview, so there is no test script to overwrite:

"scripts": {
  "dev": "vite",
  "build": "vite build",
  "test": "vitest",
  "test:run": "vitest run"
}

Run npm run dev and the app serves on port 5173. Run npm test and Vitest starts in watch mode.

Write Your First React Unit Test

Start with a component that has one piece of conditional rendering and one callback, because that combination exercises both halves of a unit test:

// src/components/TaskItem.jsx
export default function TaskItem({ task, onToggle }) {
  return (
    <li>
      <span>{task.text}</span>
      <button onClick={() => onToggle(task.id)}>
        {task.done ? 'Mark incomplete' : 'Mark complete'}
      </button>
    </li>
  );
}

The test renders the component, finds elements the way a user would, and asserts on what changed:

// src/components/TaskItem.test.jsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import TaskItem from './TaskItem';

const task = { id: 7, text: 'Write unit tests', done: false };

describe('TaskItem', () => {
  it('renders the task text', () => {
    render(<TaskItem task={task} onToggle={() => {}} />);
    expect(screen.getByText('Write unit tests')).toBeInTheDocument();
  });

  it('labels the button by completion state', () => {
    render(<TaskItem task={{ ...task, done: true }} onToggle={() => {}} />);
    expect(screen.getByRole('button', { name: 'Mark incomplete' })).toBeInTheDocument();
  });

  it('passes the task id to onToggle when clicked', async () => {
    const user = userEvent.setup();
    const onToggle = vi.fn();
    render(<TaskItem task={task} onToggle={onToggle} />);

    await user.click(screen.getByRole('button', { name: 'Mark complete' }));

    expect(onToggle).toHaveBeenCalledWith(7);
  });
});

Running npx vitest run --reporter=verbose against that file on the exact stack installed above gives:

 RUN  v4.1.10 ~/react-unit-testing

 checkmark src/components/TaskItem.test.jsx > TaskItem > renders the task text 57ms
 checkmark src/components/TaskItem.test.jsx > TaskItem > labels the button by completion state 188ms
 checkmark src/components/TaskItem.test.jsx > TaskItem > passes the task id to onToggle when clicked 84ms

 Test Files  1 passed (1)
      Tests  3 passed (3)
   Start at  22:18:10
   Duration  3.53s (transform 89ms, setup 359ms, import 269ms, tests 335ms, environment 1.98s)

The environment line is worth noting: spinning up jsdom took 1.98s while the three tests themselves took 335ms combined. That startup cost is paid once per worker, which is why a suite of 200 unit tests still finishes in seconds.

Three details in that file carry most of the value. userEvent.setup() is called inside the test rather than at module scope, which keeps each test's event state independent. Every user interaction is awaited, because user-event dispatches a realistic event sequence asynchronously. And the last assertion checks the argument, not just that the callback fired, which is the difference between catching a wrong task id and missing it.

Note

Note: Unit tests confirm your components behave. Real browsers confirm they render. Run both with TestMu AI. Start free today!

Query and Assert Like a User

React Testing Library ranks its queries, and following that order is what makes a test double as an accessibility check. Work down this list and stop at the first one that fits.

QueryUse whenExample
getByRoleThe element has an implicit or explicit ARIA role. The default choice for buttons, links, headings, and inputs.getByRole('button', { name: 'Save' })
getByLabelTextQuerying a form field, since it also proves the label is wired to the input.getByLabelText('Email address')
getByTextNon-interactive content such as a paragraph or an error message.getByText('No tasks yet')
getByTestIdNothing else identifies the element. A last resort, because users cannot see a test id.getByTestId('task-row')

Each query comes in three variants, and picking the wrong one causes most flaky-looking failures. getBy throws immediately when there is no match, which is what you want for something that should already be present. queryBy returns null instead of throwing, so it is the only variant that can assert absence. findBy returns a promise and retries until a timeout, so it is the one to use when the element appears after an async update.

expect(screen.getByRole('heading', { name: 'Tasks' })).toBeInTheDocument();
expect(screen.queryByText('No tasks yet')).not.toBeInTheDocument();
expect(await screen.findByRole('listitem')).toHaveTextContent('Write unit tests');

The jest-dom matchers are what keep assertions readable: toBeInTheDocument, toBeVisible, toBeDisabled, toHaveValue, toHaveTextContent, and toHaveAccessibleName. Prefer them over hand-rolled checks on className or textContent, since they fail with a message that names the element. When no role, label, or text can identify a node, a data-testid is the documented escape hatch.

Test Hooks, State, and Async Updates

A custom hook is pure logic, so test it directly with renderHook rather than through a component built only for the test. Wrap any call that triggers a state update in act so React flushes it before you assert:

import { renderHook, act } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import useTaskCount from './useTaskCount';

describe('useTaskCount', () => {
  it('starts at zero and increments', () => {
    const { result } = renderHook(() => useTaskCount());

    expect(result.current.count).toBe(0);
    act(() => result.current.increment());
    expect(result.current.count).toBe(1);
  });
});

Note that act now comes from @testing-library/react. The old react-dom/test-utils entry point has been removed, and react-test-renderer is deprecated, so any tutorial importing act from either one predates React 19.

For asynchronous UI, reach for findBy before waitFor. A findBy query already retries and already fails with a useful message, so it replaces most manual waiting:

it('shows tasks once the request resolves', async () => {
  render(<TaskList />);

  expect(screen.getByText('Loading tasks')).toBeInTheDocument();
  expect(await screen.findByText('Write unit tests')).toBeInTheDocument();
});

Keep waitFor for the cases findBy cannot express, such as waiting for something to disappear or for a mock to have been called. Put a single assertion inside the callback and never a side effect, because waitFor may run the callback many times before it passes.

await waitFor(() => expect(saveTask).toHaveBeenCalledTimes(1));
await waitForElementToBeRemoved(() => screen.queryByText('Loading tasks'));

An "act" warning in the console almost always means a state update landed after the test finished, usually an unawaited promise. Awaiting the interaction or the findBy query fixes the warning at its source, and wrapping more code in act only hides it.

Mock Props, Modules, and API Calls

Mocking is what keeps a unit test a unit test. Three levels cover almost everything, and reaching for the heaviest one first is a common mistake.

A callback prop needs nothing more than a spy:

const onSave = vi.fn();
render(<TaskForm onSave={onSave} />);

A module the component imports is replaced with vi.mock, which is hoisted above the imports so it applies before the component loads:

import { vi } from 'vitest';
import { fetchTasks } from '../api/tasks';

vi.mock('../api/tasks', () => ({
  fetchTasks: vi.fn(),
}));

it('renders tasks returned by the API', async () => {
  fetchTasks.mockResolvedValue([{ id: 7, text: 'Write unit tests', done: false }]);

  render(<TaskList />);

  expect(await screen.findByText('Write unit tests')).toBeInTheDocument();
});

Browser APIs that jsdom does not implement have to be stubbed explicitly. localStorage exists in jsdom, but matchMedia, IntersectionObserver, and ResizeObserver do not, and a component using any of them throws before it renders:

vi.stubGlobal('matchMedia', vi.fn().mockReturnValue({
  matches: false,
  addEventListener: vi.fn(),
  removeEventListener: vi.fn(),
}));

Call vi.clearAllMocks() in a beforeEach, or set clearMocks to true in the Vitest config, so call counts do not leak between tests. When a suite starts mocking so much that the mocks outweigh the component, that is the signal the behaviour belongs in an integration test instead.

Migrate Off Enzyme and react-test-renderer

Enzyme has not shipped a release since 3.11.0 in December 2019. It is not formally deprecated and its repository is not archived, but no official adapter exists beyond React 16, the community React 17 and 18 adapters have been dormant since 2022 and 2024, and there is no React 19 adapter at all. Treat it as unmaintained. react-test-renderer is deprecated outright, and react-dom/test-utils has been removed.

Most of the migration is mechanical. The part that is not is philosophical: Enzyme let you inspect state and props directly, and React Testing Library deliberately does not, so tests asserting on internals have to be rewritten to assert on rendered output.

EnzymeReact Testing Library
shallow(<TaskItem />)render(<TaskItem />), with child modules mocked when isolation matters
mount(<TaskItem />)render(<TaskItem />)
wrapper.find('.save-button')screen.getByRole('button', { name: 'Save' })
wrapper.simulate('click')await user.click(element)
wrapper.state() and wrapper.setState()No equivalent by design. Assert on what the state change renders.
wrapper.props()No equivalent. Assert on the DOM the props produce.
wrapper.text()screen.getByText(), or toHaveTextContent on the container

Migrate one file at a time rather than in a single pass. Both libraries can run in the same suite while the move is in progress, so the practical order is to convert the tests that break first, then the tests that touch state, and delete the Enzyme adapter once nothing imports it.

Where jsdom Stops and Real Browsers Start

Every test above ran in jsdom, which is a JavaScript implementation of DOM and HTML standards, not a browser. That distinction is not academic, and it decides which bugs your unit suite can never catch.

jsdom has no layout or rendering engine. getBoundingClientRect returns zeros, offsetWidth and offsetHeight are always zero, and no CSS is applied, so a test cannot tell you that a modal renders off-screen, that a sticky header covers the submit button, or that a flex container wraps at a particular width. It also ships no navigation, no real network stack, and only the subset of browser APIs it has implemented.

More importantly, jsdom is one implementation. It cannot reproduce a Safari-only date-input behaviour or a Firefox-specific focus difference, because those are properties of the engines themselves. A component with 100% unit coverage can still be broken in the browser half your users run.

That gap is where cloud execution belongs. TestMu AI's automation cloud runs Selenium, Cypress, Playwright, and Puppeteer suites across 3,000+ real browser and OS combinations on real browser engines rather than headless-only mocks, in parallel, with full session artifacts on every run. The division of labour is straightforward: unit tests in jsdom prove the component logic on every commit, and a smaller cross-browser suite proves the rendering holds where the engines actually differ. The Playwright testing documentation covers connecting an existing suite.

Test across 3000+ browser and OS environments with TestMu AI

Run React Unit Tests in CI with Coverage

CI needs the single-run mode, not watch mode. vitest run exits with a non-zero code when a test fails, which is what makes the pipeline stop:

npm install -D @vitest/coverage-v8
"scripts": {
  "test:run": "vitest run",
  "coverage": "vitest run --coverage"
}

Set thresholds so coverage cannot silently drift downward, and exclude the files that would otherwise inflate the number:

test: {
  environment: 'jsdom',
  globals: true,
  setupFiles: './src/setupTests.js',
  clearMocks: true,
  coverage: {
    provider: 'v8',
    reporter: ['text', 'lcov'],
    exclude: ['**/*.test.jsx', 'src/main.jsx', 'src/setupTests.js'],
    thresholds: { lines: 80, functions: 80, branches: 70 },
  },
}

Treat those numbers as a floor that stops regressions, not a target to chase. Coverage counts executed lines, so a suite can report 90% while asserting almost nothing. Branch coverage is the more honest signal, because it is the one that notices your error state was never rendered.

Unit tests are fast enough to run on every push. Keep them on the pull request gate, and schedule the slower cross-browser suite on merges to the main branch so feedback stays quick where it matters most.

Conclusion

Install the four packages from the setup section, write one test for the component that broke most recently, and assert on what the user sees rather than on state. That single test is worth more than a coverage target, because it encodes a bug your team already paid for.

From there, the practical order is to cover conditional branches, then callbacks, then custom hooks, adding mocks only where a dependency genuinely gets in the way. When the suite is green and you still want confidence that the UI renders correctly in Safari or an older Chrome, that is the point to add a cross-browser run on TestMu AI rather than to keep adding jsdom tests that structurally cannot answer the question.

Author

...

Harita Ravindranath

Blogs: 18

  • Twitter
  • Linkedin

Harita Ravindranath is a Full Stack Developer and Project Manager at Tokhimo Inc., with 7 years of experience in the tech industry. She has completed her graduation in B-tech in Electronics and Communication Engineering. She has 5+ years of hands on expertise in JavaScript based technologies like React.js, Next.js, TypeScript, Node.js, and Express.js, and has led 4 full stack projects from scratch. Harita also brings over 2 years of experience in Quality Engineering, including manual and automation testing using Selenium and Cypress, test strategy creation, and Agile/Scrum based development. With 4+ years in project leadership, she is skilled in managing CI/CD pipelines, cloud platforms, and ensuring high quality releases. Harita has authored 30+ technical blogs on web development and automation testing, and has worked on end to end testing for a major banking application covering UI, API, mobile, visual, and cross browser testing. She believes in building clean, efficient, and maintainable solutions by avoiding over engineering.

Reviewer

...

Rahul Mishra

Reviewer

  • Linkedin

Rahul Mishra is a Lead Member of Technical Staff at TestMu AI (formerly LambdaTest), leading frontend engineering and accessibility testing across the quality engineering platform. He mentors frontend engineers, runs code reviews and sprint planning, optimizes React.js rendering performance, and makes product features accessible to users with disabilities through WCAG and ADA-compliant accessibility audits. He brings 10+ years of experience across React.js, VueJS, TypeScript, Swift, Objective-C, and AWS, with earlier work as a Technical Lead at VectoScalar Technologies. Rahul holds a B.E. in Information Technology.

Open in ChatGPT Icon

Open in ChatGPT

Open in Claude Icon

Open in Claude

Open in Perplexity Icon

Open in Perplexity

Open in Grok Icon

Open in Grok

Open in Gemini AI Icon

Open in Gemini AI

Copied to Clipboard!
...

3000+ Browsers. One Platform.

See exactly how your site performs everywhere.

Try it free
...

Write Tests in Plain English with KaneAI

Create, debug, and evolve tests using natural language.

Try for free
...
TestMu Conf 2026

World's largest virtual agentic engineering & quality conference

...

AUG 19-21, 2026

WATCH NOW

React Unit Testing FAQs

Did you find this page helpful?

More Related Blogs

TestMu AI forEnterprise

Get access to solutions built on Enterprise
grade security, privacy, & compliance

  • Advanced access controls
  • Advanced data retention rules
  • Advanced Local Testing
  • Premium Support options
  • Early access to beta features
  • Private Slack Channel
  • Unlimited Manual Accessibility DevTools Tests