DS DevShelfHub Projects · AI tools
Cheatsheets / Vitest
Cheatsheet · Dev tooling

Vitest: Matchers, Mocks, Async Tests and Coverage Reference Guide

By DevShelfHub

Vite-native testing — matchers, mocks, spies, snapshots, async, fake timers, in-source tests, browser mode, coverage.

112 items 8 min Vite ESM vi.mock

Start hereQuick start · 6 you’ll reach for daily

Run watchvitest
CI one-shotvitest run
Name filtervitest -t "login"
UI runnervitest --ui
Mock a fnvi.fn()
Coveragevitest run --coverage

Target versions · paceVersions

Targets: vitest ≥ 2.0 vite ≥ 5 node ≥ 18 @vitest/coverage-v8 ≥ 2

Vitest 2 ships a stable Browser Mode, in-source testing, smarter watch, and project-aware workspaces. Most Jest APIs map 1:1 (jestvi, identical matchers); biggest differences are ESM-native module resolution and config living under Vite’s test: key. The vitest CLI watches by default — use vitest run in CI.

Install · configSetup

bash
# Install (works on top of Vite or standalone)
npm i -D vitest @vitest/coverage-v8
# For DOM tests
npm i -D jsdom happy-dom @testing-library/dom @testing-library/react @testing-library/jest-dom
# For UI runner
npm i -D @vitest/ui

# vitest.config.ts  (or extend vite.config.ts)
import { defineConfig } from "vitest/config";
export default defineConfig({
  test: {
    environment: "node",          // or "jsdom" | "happy-dom" | "edge-runtime"
    globals: false,               // true = auto-inject expect, vi, test, describe
    setupFiles: ["./test/setup.ts"],
    coverage: { provider: "v8", reporter: ["text", "lcov", "html"] },
  },
});

# package.json
{
  "scripts": {
    "test":      "vitest",                   # watch by default
    "test:run":  "vitest run",               # one-shot (CI)
    "test:ui":   "vitest --ui",
    "test:cov":  "vitest run --coverage"
  }
}

# Run
npx vitest                                 # watch
npx vitest run path/to/file.test.ts        # one file, no watch
npx vitest -t "login"                      # name filter
npx vitest --reporter=verbose
npx vitest --bail=1 --no-color

Where things liveCommon imports

Set test.globals: true in vitest.config.ts to auto-inject the API; otherwise import explicitly. Explicit imports give you cleaner type narrowing.

import { describe, test, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest";Core API.
import { suite, bench } from "vitest";Suite blocks + microbenchmarks (tinybench).
import { defineConfig } from "vitest/config";Strongly typed config.
import "@testing-library/jest-dom/vitest";DOM matchers (toBeInTheDocument …).
import { render, screen } from "@testing-library/react";React component testing.
import { http, HttpResponse } from "msw";HTTP mocking — works the same as in Jest.

describe · test · itWriting tests

test("name", () => { expect(x).toBe(1); })Basic test. it is an alias.
describe("group", () => { test(...); })Group + scope hooks. Nest freely.
test.each([[1,2,3],[2,3,5]])("sum %i+%i=%i", (a,b,c) => ...)Parameterised tests.
test.only / describe.onlyRun just this. Forget to remove and CI passes silently.
test.skip / test.todo("write me")Skip / scaffold.
test.concurrent("x", async () => ...)Run async tests in parallel inside a file.
test("x", { retry: 3, timeout: 10_000 }, async () => ...)Built-in Per-test options — no plugin needed.
test.fails("known regression", () => ...)Expect this test to fail; succeeds when it does.
expect.assertions(2)Guard count — catches missing await.

before / afterLifecycle hooks

beforeAll(() => ...)Once before all tests in the scope.
afterAll(() => ...)Once after. Tear down servers, DBs.
beforeEach(() => ...)Before each test. Reset state here.
afterEach(() => vi.clearAllMocks())Cleanup. Or use clearMocks: true in config.
async hooks supportedReturn a promise or use async — runner awaits.
onTestFailed(({ task }) => ...)Run only when the surrounding test failed (debug helper).

expect(...) assertionsMatchers

Equality

.toBe(value)Strict Object.is.
.toEqual(obj)Deep equality, ignores undefined keys.
.toStrictEqual(obj)Stricter — rejects extra undefined, checks classes.
.toBeCloseTo(0.3, 5)Float to n decimal digits.
.toMatchObject({ a: 1 })Partial match — extras allowed.
.toMatchInlineSnapshot()Snapshot lives inside the test file.

Truthiness, numbers, strings, arrays

.toBeTruthy() / .toBeFalsy() / .toBeNull() / .toBeUndefined() / .toBeDefined()Plain checks.
.toBeGreaterThan(n) / .toBeLessThanOrEqual(n)Numeric ordering.
.toMatch(/regex/) / .toMatch("substr")String match.
.toContain(item) / .toContainEqual(obj)Array/string contains.
.toHaveLength(n) / .toHaveProperty("a.b", v)Length / nested key.
.toSatisfy((v) => v % 2 === 0)Custom predicate.

Async & errors

await expect(p).resolves.toBe(1)Await a promise that should resolve.
await expect(p).rejects.toThrow(/oops/)Await a promise that should reject.
expect(() => fn()).toThrow(TypeError)Sync throws — wrap in arrow.

Asymmetric helpers

expect.any(String) / expect.anything()Type / non-null slot.
expect.objectContaining({ id: 1 })Partial inside a larger object.
expect.arrayContaining([1, 2])Subset of array.
expect.stringMatching(/x/)Regex inside a struct.
expect(x).not.toBe(y)Negate any matcher.

vi.fn · spy · mockMocks & spies

const fn = vi.fn()Empty mock — returns undefined.
vi.fn((x) => x + 1)Default implementation.
fn.mockReturnValue(42) / mockReturnValueOnce(42)Stage returns.
fn.mockResolvedValue(x) / mockRejectedValue(err)Async sugar.
fn.mockImplementation(impl) / mockImplementationOnce(impl)Swap behaviour.
vi.spyOn(obj, "method")Wrap a real method — original still runs.
vi.spyOn(obj, "prop", "get").mockReturnValue(v)Spy on a getter/setter.
fn.mock.calls / .results / .lastCallInspect calls after the fact.
expect(fn).toHaveBeenCalledWith(1, expect.any(Object))Argument assertion.
expect(fn).toHaveBeenNthCalledWith(2, "x")Pin a specific call.
expect(fn).toHaveResolvedWith({...})Match resolved value — async mocks only.

Module mocking

vi.mock("./db")Auto-mock all exports. Hoisted above imports.
vi.mock("./db", () => ({ getUser: vi.fn() }))Factory mock.
vi.mock("./db", async (importOriginal) => ({ ...await importOriginal(), getUser: vi.fn() }))Preferred Partial mock keeping real exports.
vi.hoisted(() => ({ x: 1 }))Run code before hoisted mocks — safe way to share values.
vi.unmock("./db") / vi.doUnmock(...)Opt out (hoisted / dynamic).
vi.importActual("./db")Get real module inside a factory.
vi.doMock(path, factory)Non-hoisted — use inside a test.
__mocks__/.tsAuto-picked manual mock when vi.mock("name") runs.
javascript
import { vi, test, expect, beforeEach } from "vitest";
import * as db from "./db";

// 1. Plain mock function
const cb = vi.fn((x: number) => x * 2);
cb(3);
expect(cb).toHaveBeenCalledWith(3);

// 2. Stage return values
const fetchUser = vi
  .fn()
  .mockResolvedValueOnce({ id: 1 })
  .mockResolvedValueOnce({ id: 2 })
  .mockRejectedValue(new Error("nope"));

// 3. Spy on a real method (keeps original)
const spy = vi.spyOn(console, "warn").mockImplementation(() => {});
// ...code under test...
spy.mockRestore();

// 4. Module mock — hoisted above imports (like Jest)
vi.mock("./db", () => ({
  getUser: vi.fn(async (id: number) => ({ id, name: "Mock" })),
}));

// 4b. Partial mock — keep real exports, swap one
vi.mock("./db", async (importOriginal) => {
  const actual = await importOriginal();
  return { ...actual, getUser: vi.fn() };
});

// 4c. Reference outer vars safely — use vi.hoisted()
const { fakeId } = vi.hoisted(() => ({ fakeId: 42 }));
vi.mock("./id", () => ({ next: () => fakeId }));

// 5. Reset between tests
beforeEach(() => {
  vi.clearAllMocks();    // call history
  // vi.resetAllMocks();  // also implementations
  // vi.restoreAllMocks();// restore spies
});
vi.mock() is hoisted. Same as Jest — the factory cannot reference local consts. Use vi.hoisted() or vi.doMock() to share values.

promises · fake timersAsync & timers

test("x", async () => { await ... })Preferred for promises.
return promiseSufficient too — runner awaits.
vi.useFakeTimers()Patch setTimeout, setInterval, queueMicrotask, Date.
vi.advanceTimersByTime(ms) / vi.advanceTimersByTimeAsync(ms)Fast-forward. Async also flushes microtasks.
vi.runAllTimers() / vi.runOnlyPendingTimersAsync()Flush all / currently scheduled.
vi.setSystemTime(new Date("2026-01-01"))Pin Date.now().
vi.useRealTimers()Restore. Always pair in afterEach.
javascript
import { vi, test, expect, afterAll } from "vitest";

// Async/await — await or return the promise
test("resolves", async () => {
  await expect(getUser(1)).resolves.toEqual({ id: 1 });
  await expect(getUser(-1)).rejects.toThrow("not found");
});

// Retry a flaky test (built-in, no plugin)
test("eventually", { retry: 3, timeout: 5_000 }, async () => {
  await expect(ping()).resolves.toBe("ok");
});

// Fake timers — vi.useFakeTimers() replaces setTimeout/Date
vi.useFakeTimers();
test("debounce fires after 250ms", async () => {
  const fn = vi.fn();
  const d = debounce(fn, 250);
  d(); d(); d();
  await vi.advanceTimersByTimeAsync(249);
  expect(fn).not.toHaveBeenCalled();
  await vi.advanceTimersByTimeAsync(1);
  expect(fn).toHaveBeenCalledTimes(1);
});

// Drain all timers + microtasks
// await vi.runAllTimersAsync();
// Pin Date.now()
// vi.setSystemTime(new Date("2026-01-01"));

afterAll(() => vi.useRealTimers());

tests next to codeIn-source tests & snapshots

if (import.meta.vitest) { const { test, expect } = import.meta.vitest; ... }Co-locate tests with source. Stripped from prod builds.
test: { includeSource: ["src/**/*.{ts,tsx}"] }Enable in vitest.config.ts.
define: { "import.meta.vitest": "undefined" }Strip the block in Vite production build.
expect(v).toMatchSnapshot()External snapshot file.
expect(v).toMatchInlineSnapshot()Preferred — snapshot lives in the test.
expect(file).toMatchFileSnapshot("__snaps__/x.html")Vitest-only Write a snapshot to its own file.
vitest -u / vitest --updateAccept current output as truth.

v8 · istanbulCoverage

vitest run --coveragePrint + write coverage/.
coverage: { provider: "v8" }Preferred Native Node coverage — fastest.
coverage: { provider: "istanbul" }Branch-accurate but slower; needs @vitest/coverage-istanbul.
coverage.include / excludeGlobs — default excludes tests, node_modules, dist.
coverage.thresholds: { lines: 80, branches: 70 }Fail when below.
coverage.reporter: ["text", "lcov", "html", "json-summary"]Pick formats. lcov for Codecov.
/* v8 ignore next */Skip a single statement. Sparingly.

vitest.config.tsConfig keys

test.environment: "node" | "jsdom" | "happy-dom" | "edge-runtime"Pick a runtime per project (or per test via doc-comment).
test.globals: trueAuto-inject expect/vi/test/describe. Off by default.
test.setupFiles: ["./test/setup.ts"]Run before every test file.
test.alias: { "@": "/src" }Path aliases. Or inherit Vite’s resolve.alias.
test.pool: "threads" | "forks" | "vmThreads"Isolation strategy. forks for native deps.
test.poolOptions.threads.singleThread: trueDisable parallelism (debug or shared state).
test.clearMocks / mockReset / restoreMocks: trueAuto-cleanup between tests. restoreMocks is the strictest.
test.workspace: "./vitest.workspace.ts"Run multiple configs (node + browser + jsdom) at once.
test.browser: { enabled: true, name: "chromium", provider: "playwright" }Browser mode Real browser, not jsdom.

flags you actually useCLI flags

vitest / vitest watchDefault Watch mode.
vitest runOne-shot. Always use in CI.
-t "regex"Filter by test name.
--reporter=verbose|dot|json|junitOutput format.
--uiBrowser UI runner (Vitest UI).
--changed / --changed=HEAD~1Run only tests touching changed files.
--bail=NStop after N failures.
--inspect-brk / --inspectAttach Node debugger.
--update / -uAccept new snapshots.
--silent / --hideSkippedTestsQuiet down output.
--project=web --project=serverFilter workspaces.

A full unit-test fileEnd-to-end · Cart checkout

Source with an in-source test next to total(), plus a separate spec covering checkout() with a mocked payment function.

javascript
// src/cart.ts
export const total = (items: { price: number; qty: number }[]) =>
  items.reduce((s, i) => s + i.price * i.qty, 0);

export async function checkout(items, pay) {     // pay: (cents) => Promise<{ok:boolean}>
  if (!items.length) throw new Error("empty cart");
  const cents = Math.round(total(items) * 100);
  const res = await pay(cents);
  if (!res.ok) throw new Error("payment failed");
  return { paid: cents };
}

// In-source tests — same file, stripped from prod builds
if (import.meta.vitest) {
  const { test, expect } = import.meta.vitest;
  test("total sums price*qty", () => {
    expect(total([{ price: 2, qty: 3 }])).toBe(6);
  });
}

// src/cart.test.ts
import { describe, test, expect, vi } from "vitest";
import { total, checkout } from "./cart";

describe("cart", () => {
  test("checkout calls pay with cents", async () => {
    const pay = vi.fn().mockResolvedValue({ ok: true });
    await expect(checkout([{ price: 9.99, qty: 1 }], pay)).resolves.toEqual({ paid: 999 });
    expect(pay).toHaveBeenCalledWith(999);
  });

  test("rejects empty cart", async () => {
    await expect(checkout([], vi.fn())).rejects.toThrow("empty cart");
  });
});

Best practiceGood to know

Mostly drop-in for Jest tests. Run codemod-jest-to-vitest or swap jestvi by hand. Inline snapshots and module factories carry over.
Use vitest run in CI, plain vitest locally. Forgetting run in CI hangs the pipeline in watch mode — the most-common Vitest paper cut.
Prefer happy-dom over jsdom when speed matters. Smaller, faster startup; jsdom is more compatible for legacy DOM corners. Workspace projects let you pick per package.

Common trapsWatch out for

Forgetting await on advanceTimersByTimeAsync. advanceTimersByTime doesn’t flush microtasks — an async setTimeout callback never runs.
Module hoisting bites the same way it does in Jest. The vi.mock() factory cannot reference top-level consts — reach for vi.hoisted().
Browser mode needs a provider (playwright / webdriverio). Enabling test.browser.enabled without installing a provider yields a cryptic launcher error.

Go deeperSee also

Vitest FAQ

What is Vitest used for?

Vitest is a Vite-native test runner for JavaScript and TypeScript. It uses the same config as your Vite project, supports ESM natively without transformation hacks, provides Jest-compatible matchers, and offers browser mode for component testing. The vitest CLI watches tests by default.

Is Vitest compatible with Jest?

Mostly yes. Vitest uses the same describe/it/expect/beforeEach/afterEach API as Jest. Most Jest tests migrate with a find-and-replace of the jest global to vi. The main differences are ESM-native module resolution, vi.mock() hoisting behavior, and configuration under Vite instead of jest.config.js.

How do mocks work in Vitest?

vi.fn() creates a spy function that records calls. vi.spyOn(obj, 'method') wraps an existing method. vi.mock('./module') replaces an entire module with auto-mocked stubs. vi.stubGlobal() sets global values. All mocks reset between tests when you call vi.clearAllMocks() or set clearMocks: true in config.

How does coverage work in Vitest?

Run vitest run --coverage to generate a coverage report. Vitest uses V8 or Istanbul — install @vitest/coverage-v8 or @vitest/coverage-istanbul. Set thresholds in vitest.config.ts under coverage.thresholds. Coverage reports can output as text, lcov, html, or json for CI pipelines.

What is the difference between Vitest and Jest?

Vitest is faster for Vite-based projects because it re-uses the Vite pipeline instead of running a separate transform. It supports ESM natively and runs tests in parallel across workers. Jest has a larger ecosystem of plugins and runners, broader community docs, and better support for non-Vite projects.