Guide

Vitest fundamentals explained

A pricing function rounds half a cent wrong and nobody notices until finance reconciles month-end totals. A date parser treats UTC midnight as yesterday in Tokyo. These bugs hide in pure logic — the layer where unit tests earn their keep. Vitest is the default test runner for the Vite ecosystem: it shares Vite's transform pipeline (esbuild, plugins, path aliases) so tests start in milliseconds, speaks a Jest-compatible API (describe, it, expect), and runs natively on ESM without brittle Babel shims. This guide covers where Vitest fits in the test pyramid, project setup and vitest.config.ts, writing and organizing tests, mocking with vi, component testing with Testing Library, coverage and watch mode, monorepo workspace projects, CI integration, a Harbor Payments fee calculator worked example, a Vitest vs Jest vs Node decision table, common pitfalls, and a production checklist. Pair it with our Vite fundamentals guide, software testing fundamentals, and Playwright E2E testing guide for the full quality stack.

What Vitest is and why teams pick it

Vitest is a test runner built by the Vite team. Instead of re-parsing your TypeScript through a separate Jest transform, it boots a Vite dev server in test mode and executes specs as native ES modules. That means your vite.config.ts aliases, @vitejs/plugin-react JSX handling, and VITE_ env stubs work identically in tests and production builds.

Core strengths

  • Speed — cold start and watch reruns are dramatically faster than Jest on large Vite codebases because transforms are cached like HMR.
  • Jest-compatible APIdescribe/it, matchers, snapshots, and vi.mock mirror Jest closely; many teams migrate by swapping imports.
  • ESM-first — no moduleNameMapper hacks for import.meta.env or .ts extensions.
  • TypeScript native — type-aware tests via vitest/globals or explicit imports; pairs with strict tsconfig projects.
  • Watch UI — optional browser UI for filtering failing files during local TDD loops.

Vitest is not a browser runner. For DOM integration across real layout engines, use Playwright or Vitest's browser mode (Playwright/WebdriverIO providers) when you need more than jsdom approximations.

Project setup and configuration

Add Vitest to an existing Vite app:

npm install -D vitest @vitest/coverage-v8 jsdom
# React component tests:
npm install -D @testing-library/react @testing-library/user-event

Extend your Vite config or split a dedicated test config:

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

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,           // describe/it/expect without imports
    environment: "jsdom",    // or "node" for pure logic
    setupFiles: ["./src/test/setup.ts"],
    coverage: {
      provider: "v8",
      reporter: ["text", "lcov"],
      thresholds: { lines: 80 },
    },
    include: ["src/**/*.{test,spec}.{ts,tsx}"],
  },
});

Package scripts:

"scripts": {
  "test": "vitest",
  "test:run": "vitest run",
  "test:coverage": "vitest run --coverage"
}

Setup files and test doubles

A setupFiles entry runs before each test file — ideal for extending matchers (@testing-library/jest-dom/vitest), resetting MSW handlers, or stubbing matchMedia. Keep setup lean; heavy global mocks slow every spec.

Reuse production aliases from Vite: resolve.alias maps @/ to src/ in both app and test configs when you merge via defineConfig from vitest/config.

Writing tests: structure and matchers

Follow arrange-act-assert (see our testing fundamentals): build inputs, call the unit under test, assert outputs. Group related cases in describe blocks; name it strings as behavior, not implementation (“applies volume discount above 10,000 units” not “calls calculateTier”).

import { describe, it, expect } from "vitest";
import { formatLamports } from "./format";

describe("formatLamports", () => {
  it("converts whole SOL without trailing zeros", () => {
    expect(formatLamports(1_000_000_000n)).toBe("1 SOL");
  });

  it("shows up to nine decimal places for fractional amounts", () => {
    expect(formatLamports(1_500_000n)).toBe("0.0015 SOL");
  });
});

Matchers you will use daily

  • toBe / toEqual — primitives vs deep object equality.
  • toThrow / rejects.toThrow — error paths and async failures.
  • toMatchObject — partial struct matching for API responses.
  • expect.soft — collect multiple failures in one test (UI snapshots).

Use test.concurrent only for independent async I/O; shared mutable state and database tests should stay sequential to avoid order-dependent flakes.

Mocking with vi

Vitest exposes the vi namespace for mocks, spies, and timers. Prefer mocking at module boundaries (HTTP clients, clock, filesystem) rather than every internal helper — over-mocking makes refactors painful.

import { vi, describe, it, expect, beforeEach } from "vitest";
import { fetchRate } from "./api";
import { convertUsdToSol } from "./convert";

vi.mock("./api", () => ({
  fetchRate: vi.fn(),
}));

describe("convertUsdToSol", () => {
  beforeEach(() => {
    vi.mocked(fetchRate).mockReset();
  });

  it("multiplies USD by the live SOL price", async () => {
    vi.mocked(fetchRate).mockResolvedValue(150);
    await expect(convertUsdToSol(300)).resolves.toBe(2);
  });
});

Timers, modules, and hoisting

  • vi.useFakeTimers() + vi.advanceTimersByTime(ms) for debounce and polling logic.
  • vi.importActual — partial mocks that keep real exports except one function.
  • vi.hoisted — define mock state before vi.mock factories run (top-level hoisting rules).
  • vi.resetModules() — reload modules when tests mutate singletons (use sparingly).

For HTTP, many teams pair Vitest with MSW (Mock Service Worker) to intercept fetch at the network layer instead of mocking fetch per test — closer to production behavior.

Component and integration tests

React component tests use @testing-library/react with jsdom:

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

it("shows updated total when user edits amount", async () => {
  const user = userEvent.setup();
  render(<FeePreview baseBps={30} />);
  await user.clear(screen.getByLabelText(/amount/i));
  await user.type(screen.getByLabelText(/amount/i), "250");
  expect(screen.getByText(/fee: \$0\.75/i)).toBeInTheDocument();
});

Query by role and accessible name first (getByRole('button', { name: /pay/i })) — resilient to CSS refactors. Avoid testing implementation details (internal state variable names). Wrap components that need context providers (React Query, Router) in minimal test harnesses shared via a renderWithProviders helper.

Vue projects use @vue/test-utils; Svelte uses @testing-library/svelte. Vitest's environment stays jsdom; plugin config matches your React or Vue Vite setup.

Coverage, watch mode, and monorepos

vitest run --coverage emits lcov for CI dashboards (Codecov, Sonar). Set coverage.thresholds to fail builds when critical packages drop below team standards — but do not chase 100% on UI glue; focus on pricing, auth, and parsing modules.

Watch mode (vitest without run) reruns only affected files via Vite's module graph — ideal TDD on utility libraries. Press a to run all, f to filter failures.

Workspace projects

Monorepos define multiple test projects in one config:

export default defineConfig({
  test: {
    projects: [
      { test: { name: "unit", environment: "node", include: ["packages/core/**/*.test.ts"] } },
      { test: { name: "ui", environment: "jsdom", include: ["packages/ui/**/*.test.tsx"] } },
    ],
  },
});

Each project can set its own environment, setup files, and coverage roots. Run a single project with vitest --project unit.

CI integration

CI should call vitest run (not watch). Typical GitHub Actions step:

- run: npm ci
- run: npm run test:coverage
  env:
    CI: true

CI=true disables interactive watch and can reduce default thread count for stable runners. Pair unit tests with lint and typecheck before Playwright in the pipeline — fail fast on cheap checks. Cache node_modules and Vitest's cacheDir between runs for sub-minute PR feedback. See our CI/CD pipelines guide for gate ordering and artifact uploads.

Worked example: Harbor Payments fee calculator

Harbor Payments exposes a merchant dashboard where sellers preview platform fees before listing SKUs. The fee engine lives in a pure TypeScript module (basis points, volume tiers, currency rounding) consumed by both the React UI and a Node settlement microservice. Vitest guards the shared logic.

Test layout

  • src/fees/calculateFee.ts — pure functions, no React imports.
  • src/fees/calculateFee.test.ts — table-driven cases for tiers and rounding.
  • src/components/FeePreview.test.tsx — jsdom tests for labels and live preview.
  • src/test/setup.ts — jest-dom matchers, fixed Intl locale.
// calculateFee.test.ts (excerpt)
import { describe, it, expect } from "vitest";
import { calculateFee } from "./calculateFee";

describe("calculateFee", () => {
  it.each([
    { amount: 10_00, bps: 30, expected: 30 },
    { amount: 99_99, bps: 30, expected: 300 },
    { amount: 50_000_00, bps: 25, expected: 12_500_00 },
  ])("charges $expected cents on $amount at $bps bps", ({ amount, bps, expected }) => {
    expect(calculateFee({ amountCents: amount, basisPoints: bps })).toBe(expected);
  });

  it("throws when basis points exceed 10000", () => {
    expect(() => calculateFee({ amountCents: 100, basisPoints: 10001 }))
      .toThrow(/invalid basis points/i);
  });
});

The Node service imports the same module in integration tests with environment: "node" — no jsdom overhead. Playwright covers the checkout button end-to-end; Vitest owns the math that must never drift.

Tooling decision table

Need Vitest Jest Node test runner
Vite + ESM frontend Best fit Works with config friction Possible, no DOM defaults
Create React App (Webpack) Migration effort Legacy default Rare
Pure Node library Good (node env) Good Built-in, zero deps
Snapshot + mock ecosystem Jest-compatible Largest plugins Minimal
Watch speed on 500+ files Fast (Vite cache) Slower cold start Fast, fewer features
Real browser layout browser mode or Playwright jest-puppeteer (dated) Not applicable

Common pitfalls

  • Testing implementation details — asserting internal state instead of user-visible outcomes; brittle on refactors.
  • Global mock leakage — forgetting vi.clearAllMocks() or mockReset between tests causes order-dependent failures.
  • jsdom for layout — offsetWidth and CSS grid behave differently than Chrome; use Playwright for visual regressions.
  • Duplicating E2E in unit tests — full checkout flows belong in Playwright; unit tests should slice one function.
  • Coverage theater — 95% line coverage with no assertions on edge cases; use mutation testing or review critical tables.
  • Async without await — floating promises make tests pass while behavior fails; always return or await async expectations.
  • Snapshot sprawl — huge HTML snapshots nobody reads; prefer explicit role queries.
  • Mixing test environments — Node crypto tests accidentally running in jsdom add polyfill noise; split projects.

Production checklist

  • Add vitest and test:run script; wire into CI on every PR.
  • Share Vite aliases and plugins between app and test config.
  • Choose node vs jsdom per package; avoid jsdom for pure logic.
  • Create setupFiles for jest-dom and shared providers.
  • Table-drive pricing, parsing, and date utilities with it.each.
  • Mock at HTTP/module boundaries; prefer MSW for fetch-heavy code.
  • Set coverage thresholds on fee, auth, and serialization modules.
  • Keep component tests focused on interaction, not CSS class names.
  • Run vitest run before Playwright in CI for fast feedback.
  • Document how to run a single file: vitest src/fees/calculateFee.test.ts.

Key takeaways

  • Vitest is the natural unit-test layer for Vite apps — same transforms, native ESM, Jest-like API.
  • Pure logic (fees, parsers, validators) deserves table-driven tests before UI or E2E coverage.
  • vi.mock and MSW isolate external IO without brittle internals.
  • Testing Library queries keep component tests aligned with accessibility.
  • Playwright complements Vitest; neither replaces the other in a healthy pyramid.

Related reading