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 moduleat 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-jestdance. - Compatible with Jest. Most Jest tests run unchanged if you swap
jestforvi.
Installing the stack
npm i -D vitest @testing-library/react @testing-library/user-event @testing-library/jest-dom jsdomThen point Vitest at jsdom (so document exists in tests) and load the Testing Library matchers globally:
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"],
},
});import "@testing-library/jest-dom/vitest";describe, it, expect
Three primitives. describe groups, it (or its alias test) declares a case, expect asserts.
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.
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.
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)
getByRolewith thenameoption. Most accessible, most resilient.getByLabelTextfor form fields.getByPlaceholderText,getByTextas fallbacks.getByTestIdas the last resort. Only usedata-testidwhen nothing else works.
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.
// 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.
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.
it("formats currency", () => {
expect(formatUSD(1234.5)).toMatchInlineSnapshot(`"$1,234.50"`);
});toMatchInlineSnapshot so the expected value lives next to the test. Review snapshot diffs in code review like any other code.Quiz
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.
describegroups,itdeclares,expectasserts. Watch mode is the default.- React Testing Library queries by role, label, then text. Test IDs are a last resort.
- Use
userEventfor interactions, notfireEvent. - Mock modules and time with
vi.mock,vi.useFakeTimers. - Snapshot small, inline outputs. Never snapshot whole pages.