Skip to content

Vitest Reference: Config, Mocking, React Testing Library, Snapshots & MSW

Vitest is Vite-native testing — same config, same transforms, no separate Babel setup. It’s Jest-compatible enough that you can migrate most tests by changing the import. The advantages: faster startup (no JIT compilation overhead), native ESM, TypeScript without config, and in-source testing. The one thing to know before switching from Jest: Vitest runs in a Vite environment, so some Node.js-specific mocking patterns differ.

1. Setup & Config

Install, vitest.config.ts, vite.config.ts integration, and globals
# npm install -D vitest @vitest/coverage-v8

// vitest.config.ts (or inline in vite.config.ts):
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    globals: true,         // enables describe/it/expect without imports (Jest-compatible)
    environment: "jsdom",  // or "node" (default), "happy-dom" (faster than jsdom)
    setupFiles: ["./src/test/setup.ts"],   // runs before each test file
    coverage: {
      provider: "v8",
      reporter: ["text", "json", "html"],
      thresholds: { lines: 80, functions: 80, branches: 70 },
    },
    // Alias paths (must match tsconfig paths):
    alias: { "@": "/src" },
  },
});

// src/test/setup.ts — global test setup:
import "@testing-library/jest-dom";    // extends expect with .toBeInTheDocument() etc.
import { beforeAll, afterAll, afterEach } from "vitest";
import { cleanup } from "@testing-library/react";

afterEach(() => cleanup());   // unmount components after each test

// package.json scripts:
// "test": "vitest",
// "test:ui": "vitest --ui",         // browser UI for tests
// "test:coverage": "vitest run --coverage",
// "test:watch": "vitest --watch"    // vitest is already watch mode by default

2. Test Syntax & Assertions

describe/it/test, expect matchers, async tests, and .each for data-driven tests
import { describe, it, expect, beforeEach, afterEach, beforeAll } from "vitest";

describe("UserService", () => {
  let db: Database;

  beforeAll(async () => {
    db = await createTestDatabase();
  });

  beforeEach(async () => {
    await db.seed();
  });

  afterEach(async () => {
    await db.truncate();
  });

  it("creates a user", async () => {
    const user = await UserService.create({ name: "Alice", email: "alice@example.com" });
    expect(user.id).toBeDefined();
    expect(user.name).toBe("Alice");
    expect(user.email).toBe("alice@example.com");
  });

  it("throws on duplicate email", async () => {
    await UserService.create({ email: "dupe@example.com" });
    await expect(UserService.create({ email: "dupe@example.com" })).rejects.toThrow("duplicate");
  });
});

// .each: data-driven tests:
describe.each([
  { input: "hello",   expected: "HELLO" },
  { input: "world",   expected: "WORLD" },
  { input: "foo bar", expected: "FOO BAR" },
])("toUpperCase($input)", ({ input, expected }) => {
  it("converts correctly", () => {
    expect(input.toUpperCase()).toBe(expected);
  });
});

// Common expect matchers:
expect(value).toBe(42);               // strict equality (===)
expect(obj).toEqual({ a: 1 });        // deep equality
expect(arr).toHaveLength(3);
expect(arr).toContain("item");
expect(obj).toHaveProperty("key.nested", "value");
expect(fn).toThrow(/error message/);
expect(promise).resolves.toBe("ok");
expect(promise).rejects.toThrow("error");
expect(mock).toHaveBeenCalledWith("arg1", "arg2");
expect(mock).toHaveBeenCalledTimes(2);

3. Mocking — vi.mock, vi.fn, vi.spyOn

Module mocks, function spies, timers, and auto-mocking
import { vi, describe, it, expect, beforeEach } from "vitest";

// Mock a module (hoisted to top of file like Jest):
vi.mock("../lib/email", () => ({
  sendEmail: vi.fn().mockResolvedValue({ messageId: "test-123" }),
}));

// Import after mock — gets the mocked version:
import { sendEmail } from "../lib/email";

it("sends a welcome email on registration", async () => {
  await UserService.register({ email: "alice@example.com" });
  expect(sendEmail).toHaveBeenCalledWith({
    to: "alice@example.com",
    subject: "Welcome!",
    body: expect.stringContaining("Welcome"),
  });
});

// vi.fn(): standalone mock function:
const mockFn = vi.fn().mockReturnValue(42);
const mockAsync = vi.fn().mockResolvedValue({ data: "result" });
// Sequence of return values:
const mockSeq = vi.fn().mockReturnValueOnce(1).mockReturnValueOnce(2).mockReturnValue(3);

// vi.spyOn(): spy on an existing method (preserves implementation):
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
// After test:
spy.mockRestore();

// Reset between tests:
beforeEach(() => {
  vi.clearAllMocks();    // clears call counts but keeps implementation
  // vi.resetAllMocks(); // resets to vi.fn() with no implementation
  // vi.restoreAllMocks(); // restores original implementations (for spyOn)
});

// Fake timers:
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-03-14"));
vi.advanceTimersByTime(5000);   // advance 5 seconds
vi.runAllTimers();               // run all pending timers
vi.useRealTimers();

4. React Component Testing

@testing-library/react with Vitest, user events, and async updates
// npm install -D @testing-library/react @testing-library/user-event @testing-library/jest-dom

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

describe("LoginForm", () => {
  it("calls onSubmit with email and password", async () => {
    const user = userEvent.setup();   // always use setup() not userEvent directly
    const onSubmit = vi.fn();

    render(<LoginForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText("Email"), "alice@example.com");
    await user.type(screen.getByLabelText("Password"), "secret123");
    await user.click(screen.getByRole("button", { name: "Login" }));

    expect(onSubmit).toHaveBeenCalledWith({
      email: "alice@example.com",
      password: "secret123",
    });
  });

  it("shows validation error for invalid email", async () => {
    const user = userEvent.setup();
    render(<LoginForm onSubmit={vi.fn()} />);

    await user.type(screen.getByLabelText("Email"), "not-an-email");
    await user.click(screen.getByRole("button", { name: "Login" }));

    expect(screen.getByText(/invalid email/i)).toBeInTheDocument();
  });

  it("shows loading state while submitting", async () => {
    const user = userEvent.setup();
    const onSubmit = vi.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
    render(<LoginForm onSubmit={onSubmit} />);

    await user.type(screen.getByLabelText("Email"), "alice@example.com");
    await user.click(screen.getByRole("button", { name: "Login" }));

    expect(screen.getByRole("button")).toBeDisabled();
    await waitFor(() => expect(screen.getByRole("button")).not.toBeDisabled());
  });
});

5. In-Source Testing, Snapshots & MSW

Co-locate tests with source, snapshot testing, and Mock Service Worker for API mocking
// In-source testing (Vitest-native — tests alongside source code):
// utils/format.ts:
export function formatCurrency(amount: number, currency = "GBP"): string {
  return new Intl.NumberFormat("en-GB", { style: "currency", currency }).format(amount);
}

// In-source test block (tree-shaken in production):
if (import.meta.vitest) {
  const { it, expect } = import.meta.vitest;
  it("formats GBP", () => {
    expect(formatCurrency(1234.56)).toBe("£1,234.56");
  });
  it("formats USD", () => {
    expect(formatCurrency(1234.56, "USD")).toBe("US$1,234.56");
  });
}

// Snapshots (use sparingly — brittle, but useful for complex output):
it("renders user card", () => {
  const { container } = render(<UserCard user={{ name: "Alice", role: "admin" }} />);
  expect(container).toMatchSnapshot();   // creates/updates __snapshots__/ file
});
// Update snapshots: vitest run --update

// MSW (Mock Service Worker) for API mocking without module mocks:
// npm install -D msw
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";

const server = setupServer(
  http.get("/api/users", () => HttpResponse.json([{ id: 1, name: "Alice" }])),
  http.post("/api/users", async ({ request }) => {
    const body = await request.json();
    return HttpResponse.json({ id: 2, ...body }, { status: 201 });
  }),
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Track Node.js and testing toolchain at ReleaseRun. Related: TypeScript Reference | React Reference | Next.js App Router Reference

🔍 Free tool: npm Package Health Checker — check Vitest and related packages — @testing-library, happy-dom — for known CVEs and latest versions.

Founded

2023 in London, UK

Contact

hello@releaserun.com