Gerson

Gerson

Passionate developer specializing in web development, cloud architecture, and system design.

TypeScriptReactNext.jsPythonFastAPISQLNode.jsAWS

Testing Modern React Applications with Vitest and Testing Library

A practical guide to testing React components, hooks, and async interactions using Vitest and React Testing Library. Covers component tests, user events, mocking, and testing Server Components.

Gersonhttps://vitest.dev/
Testing and quality assurance concept with code on screen

Testing React applications has gotten significantly better in the last couple of years. Vitest has largely replaced Jest as the test runner of choice (faster, native ESM support, Vite-compatible), and React Testing Library remains the standard for component testing with its focus on testing behavior rather than implementation.

This guide covers the practical patterns I use for testing React applications in 2026, with TypeScript examples throughout.

Setting Up Vitest

Terminal

npm install -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event jsdom

vitest.config.ts

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

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

src/test/setup.ts

import "@testing-library/jest-dom/vitest";

Testing Components

The core principle of React Testing Library is to test your components the way users interact with them — by text content, labels, and roles rather than CSS selectors or component internals.

TodoList.test.tsx

import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TodoList } from "./TodoList";

describe("TodoList", () => {
  it("adds a new todo when the form is submitted", async () => {
    const user = userEvent.setup();
    render(<TodoList />);

    const input = screen.getByPlaceholderText("Add a todo...");
    const button = screen.getByRole("button", { name: /add/i });

    await user.type(input, "Buy groceries");
    await user.click(button);

    expect(screen.getByText("Buy groceries")).toBeInTheDocument();
    expect(input).toHaveValue(""); // Input cleared after submit
  });

  it("marks a todo as completed when clicked", async () => {
    const user = userEvent.setup();
    render(<TodoList initialTodos={[{ id: "1", text: "Test", completed: false }]} />);

    const checkbox = screen.getByRole("checkbox");
    await user.click(checkbox);

    expect(checkbox).toBeChecked();
  });
});

Testing Async Behavior

For components that fetch data or perform async operations, use waitFor and findBy queries:

UserProfile.test.tsx

import { render, screen, waitFor } from "@testing-library/react";
import { UserProfile } from "./UserProfile";
import { http, HttpResponse } from "msw";
import { server } from "../test/mocks/server";

it("displays the user profile after loading", async () => {
  server.use(
    http.get("/api/users/1", () =>
      HttpResponse.json({ name: "Gerson", email: "dev@example.com" })
    )
  );

  render(<UserProfile userId="1" />);

  // Initially shows loading state
  expect(screen.getByText(/loading/i)).toBeInTheDocument();

  // Wait for data to load
  expect(await screen.findByText("Gerson")).toBeInTheDocument();
  expect(screen.queryByText(/loading/i)).not.toBeInTheDocument();
});

it("shows an error message when the API fails", async () => {
  server.use(
    http.get("/api/users/1", () => HttpResponse.error())
  );

  render(<UserProfile userId="1" />);

  await waitFor(() => {
    expect(screen.getByRole("alert")).toHaveTextContent(/failed to load/i);
  });
});

Testing Custom Hooks

For hooks that encapsulate complex logic, use renderHook:

useDebounce.test.ts

import { renderHook, act } from "@testing-library/react";
import { useDebounce } from "./useDebounce";

it("debounces the value by the specified delay", async () => {
  vi.useFakeTimers();

  const { result, rerender } = renderHook(
    ({ value, delay }) => useDebounce(value, delay),
    { initialProps: { value: "hello", delay: 300 } }
  );

  expect(result.current).toBe("hello");

  rerender({ value: "hello world", delay: 300 });
  expect(result.current).toBe("hello"); // Not updated yet

  act(() => { vi.advanceTimersByTime(300); });
  expect(result.current).toBe("hello world"); // Now updated

  vi.useRealTimers();
});

What to Test

Focus your testing effort where it provides the most value:

  • User interactions — Form submissions, button clicks, navigation. These are the paths users take and the most likely to break during refactoring.
  • Conditional rendering — Error states, empty states, loading states. These are easy to forget and common sources of bugs.
  • Custom hooks with logic — Debouncing, pagination, form validation. Complex state machines benefit from isolated testing.
  • Integration points — API calls, context providers, route changes. Use MSW for API mocking to test realistic data flows.

Don't test: implementation details (state values, internal methods), simple rendering of static content, or third-party library behavior.

Resources