Web development

How to use Vitest in a React project: commands, mocks and thresholds

Vitest is controlled by four things: vi.fn, vi.mock, coverage thresholds and --changed. What it really checks, and what it does not.

Key points

  • `vi.fn` records calls, `vi.mocked` types the mock of a module that is already mocked.
  • Coverage thresholds make the command fail: without a threshold the number stays a report.
  • `--changed` runs only the tests touched by the diff, but jsdom applies no CSS and no layout.

Vitest runs on three commands: vitest run for CI, vitest in watch mode and vitest run --coverage for thresholds. The version measured is vitest 5.0.5.

Where the configuration lives

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

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    setupFiles: ["./src/test/setup.ts"],
    coverage: {
      provider: "v8",
      reporter: ["text", "html"],
      thresholds: { lines: 80, functions: 80, branches: 70 },
    },
  },
});

Only Vitest reads the test block: Vite ignores it during a build. environment: "jsdom" supplies a fake DOM, and setupFiles loads jest-dom and the cleanup once per run.

Mocking a function

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

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

test("invia le credenziali", async () => {
  const onSubmit = vi.fn();
  render();

  await userEvent.type(screen.getByLabelText("Email"), "[email protected]");
  await userEvent.type(screen.getByLabelText("Password"), "segreta");
  await userEvent.click(screen.getByRole("button", { name: "Accedi" }));

  expect(onSubmit).toHaveBeenCalledWith({ email: "[email protected]", password: "segreta" });
});

vi.fn records every call with its arguments. afterEach with restoreAllMocks returns the mocks to their original state between tests, with no leakage.

Mocking a module

import { beforeEach, expect, test, vi } from "vitest";
import { fetchRates } from "./rates";

vi.mock("./rates", () => ({ fetchRates: vi.fn() }));

beforeEach(() => {
  vi.mocked(fetchRates).mockResolvedValue({ eur: 1 });
});

test("mostra il cambio", async () => {
  const rate = await fetchRates("usd");
  expect(rate.eur).toBe(1);
});

The transformer moves vi.mock to the top of the file, so it works even when written after the imports. The factory must return every export the code under test uses, or the import arrives empty.

Coverage thresholds in CI

npm i -D @vitest/coverage-v8
npx vitest run --coverage

With thresholds configured, the command exits with code one when coverage drops below the line. Without a threshold the number stays a report nobody reads.

Useful commands

npx vitest run -t "invia le credenziali"
npx vitest --changed HEAD~1
npx vitest run --bail=1
npx vitest run --project=unit

-t filters by test name, --changed runs only the files touched by the diff against a reference, --bail=1 stops at the first red. --project picks one project when the configuration declares several.

A named limitation

jsdom applies no CSS and computes no layout: a green test says the logic works, not that the component is visible. Coverage also counts executed lines, not assertions, so an imported file with no expect stays covered.

Where to continue

Three components worth a suite: sign-in, toast and otp-input; the rest sits in components/lib and the 1,556 previews. The initial setup is in Vitest: setup in ten minutes, the right queries in Testing Library: the right queries. The measured comparison of React libraries is in React libraries 2026.

Frequently asked questions

What is the difference between vi.fn and vi.spyOn?

vi.fn creates a new, empty function, useful for a callback passed as a prop. vi.spyOn wraps an existing function and lets it run, so it fits when the real code must keep working.

Why does my module mock not work?

Because the factory does not return every export the code uses, or because the path in the mock differs from the import path. The mock must use the same specifier, relative or aliased.

How do I fail the build below a threshold?

By setting thresholds inside coverage in the Vitest file. The command exits with code one when a metric drops below the value, and the pipeline stops.

How does --changed pick the tests to run?

It compares changed files with a Git reference and finds the tests that import them. Without an explicit reference it uses the last commit, so in CI pass HEAD~1 or the target branch.

Can I use Vitest without jsdom?

Yes, with environment node for code that never touches the DOM. That needs a second project in the configuration, because one environment applies to every file.