Skip to content
elephantoo

Testing with Vitest & React Testing Library

Lesson 26 of 30 20 min read

Set up Vitest, query the DOM like a user, simulate events, test async UI and mock fetch.


Tests let you change code with confidence. Instead of clicking through your app after every edit, you write small programs that do the clicking for you and check the results. In the React world the standard tools are Vitest (a fast test runner that shares your Vite config) and React Testing Library (RTL), which renders components and lets you interact with them the way a user would.

The guiding principle#

"The more your tests resemble the way your software is used, the more confidence they can give you." — Kent C. Dodds, creator of Testing Library

So don't test implementation details (state variable names, which hooks you used). Test behaviour: what the user sees and what happens when they interact. Then you can refactor freely without breaking tests.

Setup#

Terminal
npm install -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom
vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom", // a simulated browser DOM in Node
    setupFiles: "./src/test/setup.ts",
  },
});
src/test/setup.ts
import "@testing-library/jest-dom/vitest"; // adds matchers like toBeInTheDocument()
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";

afterEach(() => cleanup()); // unmount components between tests

Add a script to package.json:

JSON
{
  "scripts": {
    "test": "vitest"
  }
}

npm test runs in watch mode, re-running affected tests as you save. npx vitest run runs once (for CI). Vitest finds files named *.test.jsx, *.test.tsx and so on.

Your first test#

src/Counter.jsx
import { useState } from "react";

export function Counter({ initial = 0 }) {
  const [count, setCount] = useState(initial);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(0)} disabled={count === 0}>
        Reset
      </button>
    </div>
  );
}
src/Counter.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, test } from "vitest";
import { Counter } from "./Counter";

describe("Counter", () => {
  test("starts at the initial value", () => {
    render(<Counter initial={5} />);
    expect(screen.getByText("Count: 5")).toBeInTheDocument();
  });

  test("increments and resets", async () => {
    const user = userEvent.setup();
    render(<Counter />);

    const reset = screen.getByRole("button", { name: "Reset" });
    expect(reset).toBeDisabled();

    await user.click(screen.getByRole("button", { name: /increment/i }));
    await user.click(screen.getByRole("button", { name: /increment/i }));
    expect(screen.getByText("Count: 2")).toBeInTheDocument();

    await user.click(reset);
    expect(screen.getByText("Count: 0")).toBeInTheDocument();
  });
});

Run npx vitest run --reporter=verbose to see each test by name:

Output
 ✓ src/Counter.test.jsx > Counter > starts at the initial value 26ms
 ✓ src/Counter.test.jsx > Counter > increments and resets 153ms

 Test Files  1 passed (1)
      Tests  2 passed (2)

The pattern is always Arrange (render), Act (interact), Assert (expect).

Finding elements#

screen exposes queries that search the rendered page. Prefer them in this order:

  1. getByRole — getByRole("button", { name: "Save" }), getByRole("heading", { level: 1 }), getByRole("textbox", { name: "Email" })
  2. getByLabelText — form fields by their <label>
  3. getByPlaceholderText, getByText, getByDisplayValue
  4. getByAltText, getByTitle
  5. getByTestId — last resort, for elements with no accessible handle (data-testid="chart")

Each comes in three flavours:

VariantNot foundUse for
getBy…throwselements that should be there now
queryBy…returns nullasserting something is absent
findBy…rejects after a timeout (async)elements that appear later

Add All for multiple matches: getAllByRole("listitem").

Tip: run screen.debug() to print the current DOM, or screen.logTestingPlaygroundURL() to get a link that suggests the best query for each element.

Testing a form#

src/LoginForm.jsx
import { useState } from "react";

export function LoginForm({ onSubmit }) {
  const [error, setError] = useState("");

  function handleSubmit(e) {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    const email = data.get("email");
    const password = data.get("password");
    if (!email || !password) {
      setError("Email and password are required");
      return;
    }
    setError("");
    onSubmit({ email, password });
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" />
      <label htmlFor="password">Password</label>
      <input id="password" name="password" type="password" />
      <button type="submit">Log in</button>
      {error && <p role="alert">{error}</p>}
    </form>
  );
}
src/LoginForm.test.jsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test, vi } from "vitest";
import { LoginForm } from "./LoginForm";

test("shows an error when fields are empty", async () => {
  const user = userEvent.setup();
  const onSubmit = vi.fn();
  render(<LoginForm onSubmit={onSubmit} />);

  await user.click(screen.getByRole("button", { name: "Log in" }));

  expect(screen.getByRole("alert")).toHaveTextContent("required");
  expect(onSubmit).not.toHaveBeenCalled();
});

test("submits email and password", async () => {
  const user = userEvent.setup();
  const onSubmit = vi.fn();
  render(<LoginForm onSubmit={onSubmit} />);

  await user.type(screen.getByLabelText("Email"), "aditi@example.com");
  await user.type(screen.getByLabelText("Password"), "s3cret!");
  await user.click(screen.getByRole("button", { name: "Log in" }));

  expect(onSubmit).toHaveBeenCalledWith({ email: "aditi@example.com", password: "s3cret!" });
  expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});

vi.fn() creates a mock function that records how it was called.

Testing async code and mocking fetch#

Tests shouldn't depend on a real server — it's slow and flaky. Replace fetch with a fake:

src/UserList.jsx
import { useEffect, useState } from "react";

export function UserList() {
  const [users, setUsers] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch("/api/users")
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(setUsers)
      .catch((err) => setError(err.message));
  }, []);

  if (error) return <p role="alert">Failed: {error}</p>;
  if (!users) return <p>Loading…</p>;
  return (
    <ul>
      {users.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}
src/UserList.test.jsx
import { render, screen } from "@testing-library/react";
import { afterEach, expect, test, vi } from "vitest";
import { UserList } from "./UserList";

afterEach(() => vi.restoreAllMocks());

test("renders users from the API", async () => {
  vi.spyOn(globalThis, "fetch").mockResolvedValue(
    Response.json([
      { id: 1, name: "Aditi" },
      { id: 2, name: "Rahul" },
    ])
  );

  render(<UserList />);
  expect(screen.getByText("Loading…")).toBeInTheDocument();

  expect(await screen.findByText("Aditi")).toBeInTheDocument(); // waits for the fetch
  expect(screen.getAllByRole("listitem")).toHaveLength(2);
  expect(fetch).toHaveBeenCalledWith("/api/users");
});

test("shows an error when the request fails", async () => {
  vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("oops", { status: 500 }));

  render(<UserList />);
  expect(await screen.findByRole("alert")).toHaveTextContent("Failed: HTTP 500");
});

For bigger apps, Mock Service Worker (MSW) intercepts requests at the network level, so the same mocks work in tests and in the browser.

Testing custom hooks#

Most hooks are best tested through a component that uses them. For reusable hooks, use renderHook:

JSX
import { act, renderHook } from "@testing-library/react";
import { expect, test } from "vitest";
import { useToggle } from "./useToggle";

test("useToggle flips its value", () => {
  const { result } = renderHook(() => useToggle());
  expect(result.current[0]).toBe(false);

  act(() => result.current[1]()); // state updates outside events go in act()
  expect(result.current[0]).toBe(true);
});

Components that need providers#

If components use a router, React Query or context, wrap them in a helper:

src/test/utils.jsx
import { render } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

export function renderWithProviders(ui) {
  const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
}

For React Router, render your routes with createMemoryRouter(routes, { initialEntries: ["/courses/react"] }) and <RouterProvider>.

Timers#

JSX
import { act, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, expect, test, vi } from "vitest";

beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());

test("toast disappears after 3 seconds", () => {
  render(<Toast message="Saved!" />);
  expect(screen.getByText("Saved!")).toBeInTheDocument();

  act(() => vi.advanceTimersByTime(3000));
  expect(screen.queryByText("Saved!")).not.toBeInTheDocument();
});

Useful jest-dom matchers#

toBeInTheDocument(), toBeVisible(), toBeDisabled(), toHaveTextContent(), toHaveValue(), toBeChecked(), toHaveAttribute(), toHaveClass(), toHaveFocus(), toHaveAccessibleName().

What to test#

  • Do test user-visible behaviour: rendering, interactions, validation, loading/error states, edge cases like empty lists.
  • Do unit-test pure logic (reducers, formatters) directly — they're the cheapest tests.
  • Don't test that React works (that useState updates) or snapshot huge components.
  • Add end-to-end tests (Playwright) for a few critical flows like sign-up and checkout, running in real browsers.

Common mistakes#

  • Forgetting await on user.click / user.type, or on findBy queries.
  • Using getBy to assert absence (it throws) instead of queryBy.
  • Querying by class names or component internals, so tests break on harmless refactors.
  • Hitting real APIs in tests.
  • Wrapping everything in act() — RTL's render and userEvent already do it.

What's next#

Tests prove your app works; next make sure it works fast. Up next: performance and React DevTools.

Check your understanding

Quick quiz

0/3 answered
  1. 1.Which React Testing Library query should you usually prefer?

  2. 2.What is the difference between getBy… and findBy… queries?

  3. 3.Why use userEvent instead of fireEvent?

Finished reading?

Mark this lesson complete to track your progress.