Skip to main content
Technology & EngineeringTesting Services370 lines

Vitest

"Vitest: unit/integration testing, Vite-native, mocking (vi.mock/vi.fn), snapshots, coverage, workspace, in-source testing, benchmark"

Quick Summary18 lines
Vitest is a Vite-native testing framework that reuses your Vite configuration, plugins, and transformation pipeline for tests. This eliminates the double-configuration problem where your test runner and build tool disagree on how to handle TypeScript, JSX, CSS imports, or path aliases. Vitest provides Jest-compatible APIs so migration is straightforward, while adding Vite-specific features like hot module replacement for tests, in-source testing, and native ESM support. Tests run in isolated worker threads by default, ensuring one test file cannot leak state into another. The focus is on speed: Vitest only transforms and runs files that have changed, making the watch-mode feedback loop nearly instant.

## Key Points

- Reuse your `vite.config.ts` by extending it in `vitest.config.ts` so path aliases, plugins, and transforms stay in sync.
- Use `vi.clearAllMocks()` in `beforeEach` to prevent mock state from leaking between tests.
- Prefer `vi.mocked()` for type-safe access to mock functions rather than casting manually.
- Use `vi.useFakeTimers()` for anything time-dependent (debounce, throttle, setTimeout) and always call `vi.useRealTimers()` afterward.
- Enable coverage thresholds in CI to prevent regression in test coverage.
- Prefer inline snapshots (`toMatchInlineSnapshot`) for small values so the expected output is visible in the test file.
- Use workspace configuration to run unit, component, and integration tests with different environments in a single command.
- Do not import from `jest`; use `vitest` imports. The APIs are compatible but the runtime is different.
- Avoid mocking everything; if a function is pure and fast, test it directly without mocks.
- Do not use `vi.mock` inside `it` or `describe` blocks; it is hoisted to the top of the file regardless, which causes confusion.
- Avoid `toMatchSnapshot()` for large objects that change frequently; inline snapshots or explicit assertions are more maintainable.
- Do not rely on test execution order within a file; each test should set up its own preconditions.
skilldb get testing-services-skills/VitestFull skill: 370 lines
Paste into your CLAUDE.md or agent config

Vitest

Core Philosophy

Vitest is a Vite-native testing framework that reuses your Vite configuration, plugins, and transformation pipeline for tests. This eliminates the double-configuration problem where your test runner and build tool disagree on how to handle TypeScript, JSX, CSS imports, or path aliases. Vitest provides Jest-compatible APIs so migration is straightforward, while adding Vite-specific features like hot module replacement for tests, in-source testing, and native ESM support. Tests run in isolated worker threads by default, ensuring one test file cannot leak state into another. The focus is on speed: Vitest only transforms and runs files that have changed, making the watch-mode feedback loop nearly instant.

Setup

Installation and Configuration

// npm install -D vitest @vitest/coverage-v8 @vitest/ui

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

export default defineConfig({
  plugins: [react(), tsconfigPaths()],
  test: {
    globals: true,
    environment: "jsdom",
    setupFiles: ["./src/test/setup.ts"],
    include: ["src/**/*.{test,spec}.{ts,tsx}"],
    coverage: {
      provider: "v8",
      reporter: ["text", "json", "html", "lcov"],
      include: ["src/**/*.{ts,tsx}"],
      exclude: [
        "src/**/*.d.ts",
        "src/**/*.test.*",
        "src/**/index.ts",
        "src/test/**",
      ],
      thresholds: {
        branches: 80,
        functions: 80,
        lines: 80,
        statements: 80,
      },
    },
    pool: "threads",
    reporters: ["default", "junit"],
    outputFile: { junit: "./test-results.xml" },
  },
});

Setup File

// src/test/setup.ts
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";

afterEach(() => {
  cleanup();
  vi.restoreAllMocks();
});

// Mock browser APIs not available in jsdom
Object.defineProperty(window, "matchMedia", {
  writable: true,
  value: vi.fn().mockImplementation((query: string) => ({
    matches: false,
    media: query,
    onchange: null,
    addListener: vi.fn(),
    removeListener: vi.fn(),
    addEventListener: vi.fn(),
    removeEventListener: vi.fn(),
    dispatchEvent: vi.fn(),
  })),
});

Key Techniques

Basic Unit Testing

// src/utils/format.ts
export function formatCurrency(cents: number, locale = "en-US"): string {
  return new Intl.NumberFormat(locale, {
    style: "currency",
    currency: "USD",
  }).format(cents / 100);
}

export function slugify(text: string): string {
  return text
    .toLowerCase()
    .replace(/[^\w\s-]/g, "")
    .replace(/\s+/g, "-")
    .replace(/-+/g, "-")
    .trim();
}

// src/utils/format.test.ts
import { describe, it, expect } from "vitest";
import { formatCurrency, slugify } from "./format";

describe("formatCurrency", () => {
  it("converts cents to dollar string", () => {
    expect(formatCurrency(1099)).toBe("$10.99");
    expect(formatCurrency(0)).toBe("$0.00");
    expect(formatCurrency(100000)).toBe("$1,000.00");
  });

  it("handles negative amounts", () => {
    expect(formatCurrency(-500)).toBe("-$5.00");
  });
});

describe("slugify", () => {
  it.each([
    ["Hello World", "hello-world"],
    ["  Multiple   Spaces  ", "multiple-spaces"],
    ["Special Ch@r$!", "special-chr"],
    ["already-slugged", "already-slugged"],
  ])("converts '%s' to '%s'", (input, expected) => {
    expect(slugify(input)).toBe(expected);
  });
});

Mocking with vi.mock and vi.fn

// src/services/user.service.ts
import { db } from "../lib/database";
import { sendEmail } from "../lib/email";

export async function createUser(name: string, email: string) {
  const existing = await db.user.findUnique({ where: { email } });
  if (existing) throw new Error("Email already registered");

  const user = await db.user.create({ data: { name, email } });
  await sendEmail(email, "Welcome!", `Hello ${name}, welcome aboard!`);
  return user;
}

// src/services/user.service.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { createUser } from "./user.service";

// Module-level mock (hoisted automatically)
vi.mock("../lib/database", () => ({
  db: {
    user: {
      findUnique: vi.fn(),
      create: vi.fn(),
    },
  },
}));

vi.mock("../lib/email", () => ({
  sendEmail: vi.fn().mockResolvedValue(undefined),
}));

import { db } from "../lib/database";
import { sendEmail } from "../lib/email";

const mockFindUnique = vi.mocked(db.user.findUnique);
const mockCreate = vi.mocked(db.user.create);
const mockSendEmail = vi.mocked(sendEmail);

describe("createUser", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("creates user and sends welcome email", async () => {
    mockFindUnique.mockResolvedValue(null);
    mockCreate.mockResolvedValue({ id: "1", name: "Alice", email: "a@b.com" });

    const user = await createUser("Alice", "a@b.com");

    expect(user.id).toBe("1");
    expect(mockCreate).toHaveBeenCalledWith({
      data: { name: "Alice", email: "a@b.com" },
    });
    expect(mockSendEmail).toHaveBeenCalledWith(
      "a@b.com",
      "Welcome!",
      expect.stringContaining("Alice")
    );
  });

  it("throws when email already exists", async () => {
    mockFindUnique.mockResolvedValue({ id: "1", name: "X", email: "a@b.com" });

    await expect(createUser("Alice", "a@b.com")).rejects.toThrow(
      "Email already registered"
    );
    expect(mockCreate).not.toHaveBeenCalled();
    expect(mockSendEmail).not.toHaveBeenCalled();
  });
});

Spy and Partial Mocking

import { describe, it, expect, vi } from "vitest";

// Partial mock: only mock specific exports
vi.mock("../lib/analytics", async (importOriginal) => {
  const actual = await importOriginal<typeof import("../lib/analytics")>();
  return {
    ...actual,
    trackEvent: vi.fn(), // mock only trackEvent
  };
});

// Spying on object methods
describe("timer integration", () => {
  it("uses fake timers for debounce", () => {
    vi.useFakeTimers();
    const callback = vi.fn();
    const debounced = debounce(callback, 300);

    debounced();
    debounced();
    debounced();

    expect(callback).not.toHaveBeenCalled();
    vi.advanceTimersByTime(300);
    expect(callback).toHaveBeenCalledOnce();

    vi.useRealTimers();
  });
});

Snapshot Testing

import { describe, it, expect } from "vitest";
import { renderToString } from "react-dom/server";
import { EmailTemplate } from "./EmailTemplate";

describe("EmailTemplate", () => {
  it("matches inline snapshot", () => {
    const html = renderToString(
      <EmailTemplate userName="Alice" action="signup" />
    );

    expect(html).toMatchInlineSnapshot(
      `"<div><h1>Welcome, Alice!</h1><p>Thanks for signing up.</p></div>"`
    );
  });

  it("matches file snapshot for complex output", () => {
    const config = generateConfig({ env: "production", region: "us-east-1" });
    expect(config).toMatchSnapshot();
  });
});

Workspace Configuration for Monorepos

// vitest.workspace.ts
import { defineWorkspace } from "vitest/config";

export default defineWorkspace([
  {
    extends: "./vitest.config.ts",
    test: {
      name: "unit",
      include: ["src/**/*.test.ts"],
      environment: "node",
    },
  },
  {
    extends: "./vitest.config.ts",
    test: {
      name: "components",
      include: ["src/**/*.test.tsx"],
      environment: "jsdom",
    },
  },
  {
    extends: "./vitest.config.ts",
    test: {
      name: "integration",
      include: ["tests/integration/**/*.test.ts"],
      environment: "node",
      pool: "forks",
      testTimeout: 30_000,
    },
  },
]);

In-Source Testing

// src/utils/math.ts
export function fibonacci(n: number): number {
  if (n <= 1) return n;
  let a = 0, b = 1;
  for (let i = 2; i <= n; i++) {
    [a, b] = [b, a + b];
  }
  return b;
}

// In-source test block (stripped from production builds by Vite)
if (import.meta.vitest) {
  const { describe, it, expect } = import.meta.vitest;

  describe("fibonacci", () => {
    it("computes first ten values", () => {
      const results = Array.from({ length: 10 }, (_, i) => fibonacci(i));
      expect(results).toEqual([0, 1, 1, 2, 3, 5, 8, 13, 21, 34]);
    });
  });
}

Benchmarking

// src/utils/search.bench.ts
import { bench, describe } from "vitest";
import { linearSearch, binarySearch } from "./search";

const sorted = Array.from({ length: 10_000 }, (_, i) => i * 2);

describe("search algorithms", () => {
  bench("linear search", () => {
    linearSearch(sorted, 9998);
  });

  bench("binary search", () => {
    binarySearch(sorted, 9998);
  });
});

// Run with: vitest bench

Best Practices

  • Reuse your vite.config.ts by extending it in vitest.config.ts so path aliases, plugins, and transforms stay in sync.
  • Use vi.clearAllMocks() in beforeEach to prevent mock state from leaking between tests.
  • Prefer vi.mocked() for type-safe access to mock functions rather than casting manually.
  • Use vi.useFakeTimers() for anything time-dependent (debounce, throttle, setTimeout) and always call vi.useRealTimers() afterward.
  • Enable coverage thresholds in CI to prevent regression in test coverage.
  • Prefer inline snapshots (toMatchInlineSnapshot) for small values so the expected output is visible in the test file.
  • Use workspace configuration to run unit, component, and integration tests with different environments in a single command.

Anti-Patterns

  • Do not import from jest; use vitest imports. The APIs are compatible but the runtime is different.
  • Avoid mocking everything; if a function is pure and fast, test it directly without mocks.
  • Do not use vi.mock inside it or describe blocks; it is hoisted to the top of the file regardless, which causes confusion.
  • Avoid toMatchSnapshot() for large objects that change frequently; inline snapshots or explicit assertions are more maintainable.
  • Do not rely on test execution order within a file; each test should set up its own preconditions.
  • Avoid skipping TypeScript in test files; type errors in tests catch real bugs before they reach production.

Install this skill directly: skilldb add testing-services-skills

Get CLI access →