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 API —
describe/it, matchers, snapshots, andvi.mockmirror Jest closely; many teams migrate by swapping imports. - ESM-first — no
moduleNameMapperhacks forimport.meta.envor.tsextensions. - TypeScript native — type-aware tests via
vitest/globalsor explicit imports; pairs with stricttsconfigprojects. - 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 beforevi.mockfactories 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, fixedIntllocale.
// 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()ormockResetbetween 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
vitestandtest:runscript; wire into CI on every PR. - Share Vite aliases and plugins between app and test config.
- Choose
nodevsjsdomper package; avoid jsdom for pure logic. - Create
setupFilesfor 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 runbefore 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
- Vite fundamentals explained — dev server, HMR, and production builds
- Software testing fundamentals explained — pyramid, AAA, and flaky-test hygiene
- Playwright E2E testing explained — browser automation and CI traces
- TypeScript fundamentals explained — types, strict mode, and module patterns