webdev.complete
๐Ÿงช Testing
๐ŸšฆGoing to Production
Lesson 102 of 117
30 min

Vitest & React Testing Library

Unit + component tests. Query by accessible name.

There are two kinds of developers: ones who write tests, and ones who've been burned enough times that they've just become the first kind. Vitest plus React Testing Library is the modern default for unit-testing React components in a Vite or Next.js project. Both tools are fast, friendly, and have strong opinions about what good tests look like. Let's install them and write the kind of test that survives a refactor.

Why Vitest, not Jest?

Jest is a fine test runner but it was built for Webpack and CommonJS. Vitest is the spiritual successor: same API surface (describe, it, expect), but powered by Vite. That means:

  • ESM-native. No more SyntaxError: Cannot use import statement outside a module at 11pm.
  • Fast. Tests run in the same transform pipeline as your dev server. First run is quick, subsequent runs are nearly instant.
  • Built-in TypeScript and JSX. No Babel config, no ts-jest dance.
  • Compatible with Jest. Most Jest tests run unchanged if you swap jest for vi.

Installing the stack

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

Then point Vitest at jsdom (so document exists in tests) and load the Testing Library matchers globally:

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

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: ["./vitest.setup.ts"],
  },
});
vitest.setup.ts
import "@testing-library/jest-dom/vitest";

describe, it, expect

Three primitives. describe groups, it (or its alias test) declares a case, expect asserts.

math.test.ts
import { describe, it, expect } from "vitest";
import { add } from "./math";

describe("add", () => {
  it("sums two positives", () => {
    expect(add(2, 3)).toBe(5);
  });

  it("handles negative numbers", () => {
    expect(add(-1, 1)).toBe(0);
  });
});

Run vitest (no args) to enter watch mode. It re-runs only the tests affected by your last edit, in milliseconds. Use vitest run for a single CI-style pass that exits.

Filter while you work
Press p in watch mode to filter by filename, t to filter by test name. Use it.onlyin code to focus a single test (just don't commit it).

React Testing Library: the philosophy

Most testing libraries let you grab elements by class, ID, or any internal detail. React Testing Library refuses. Its founding rule:

The more your tests resemble the way your software is used, the more confidence they can give you.

So you query the DOM the way a user (or a screen reader) would: by role, label, or visible text. Never by class name, never by component internals.

LoginForm.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
import { LoginForm } from "./LoginForm";

describe("LoginForm", () => {
  it("submits email and password", async () => {
    const onSubmit = vi.fn();
    render(<LoginForm onSubmit={onSubmit} />);

    await userEvent.type(
      screen.getByLabelText(/email/i),
      "ada@example.com",
    );
    await userEvent.type(screen.getByLabelText(/password/i), "hunter2");
    await userEvent.click(screen.getByRole("button", { name: /sign in/i }));

    expect(onSubmit).toHaveBeenCalledWith({
      email: "ada@example.com",
      password: "hunter2",
    });
  });
});

Notice what we did not do: we never imported LoginForm's state, never checked an internal field, never looked at a class name. If we refactor the component from useStateto a reducer to a form library, this test still passes. That's the whole point.

Query priorities (memorize these)

  1. getByRole with the name option. Most accessible, most resilient.
  2. getByLabelText for form fields.
  3. getByPlaceholderText, getByText as fallbacks.
  4. getByTestId as the last resort. Only use data-testid when nothing else works.
The three variants
Each query has three forms. getBy* throws if not found (use for things that must exist). queryBy* returns null if missing (use to assert absence: expect(queryByText(...)).toBeNull()). findBy* is async and retries (use after a state update or fetch).

userEvent, not fireEvent

fireEvent.click(el) fires exactly one synthetic event. Real clicks do more: focus the element, fire mousedown, mouseup, click, sometimes blur the previous element. userEvent simulates all of that. Always prefer it.

ts
// Bad - one synthetic event, missing focus/keystrokes
fireEvent.change(input, { target: { value: "hi" } });

// Good - real keystrokes, focus, input events, the works
await userEvent.type(input, "hi");

Mocking with vi.mock

When a component calls a module you don't want to actually run (network, time, randomness), mock it.

ts
import { vi } from "vitest";

// Mock an entire module
vi.mock("./api", () => ({
  fetchUser: vi.fn().mockResolvedValue({ id: 1, name: "Ada" }),
}));

// Spy on a function without replacing it
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});

// Control time
vi.useFakeTimers();
vi.setSystemTime(new Date("2030-01-01"));
// ... run code ...
vi.useRealTimers();

Snapshot tests, with restraint

A snapshot test serializes a value to a file on the first run, then compares to that file on every subsequent run. Handy for small, stable outputs (formatted strings, generated code). Disastrous for whole rendered components, because any visual change creates a giant diff nobody actually reads, and the team starts blindly running -u.

ts
it("formats currency", () => {
  expect(formatUSD(1234.5)).toMatchInlineSnapshot(`"$1,234.50"`);
});
Snapshot rules of thumb
Snapshot small things, not whole pages. Use toMatchInlineSnapshot so the expected value lives next to the test. Review snapshot diffs in code review like any other code.

Quiz

Quiz1 / 3

Which query should you reach for first in React Testing Library?

Recap

  • Vitest is a Vite-native test runner with the Jest API, plus speed and ESM by default.
  • describe groups, it declares, expect asserts. Watch mode is the default.
  • React Testing Library queries by role, label, then text. Test IDs are a last resort.
  • Use userEvent for interactions, not fireEvent.
  • Mock modules and time with vi.mock, vi.useFakeTimers.
  • Snapshot small, inline outputs. Never snapshot whole pages.