Advanced (Updated: 6/3/2026)

Claude Code Vitest Advanced Testing Guide

Practical Vitest guide for Claude Code: mocks, fake timers, jsdom, coverage, snapshots, and CI.

Claude Code Vitest Advanced Testing Guide

What This Vitest Workflow Solves

When Claude Code is asked to “add Vitest tests” without constraints, it often produces tests that pass locally but fail around time, DOM behavior, API boundaries, or CI. This guide turns those risky areas into one practical workflow: mocks for fake dependencies, fake timers for controlled time, coverage for untested branches, jsdom for DOM structure, snapshots for small rendering contracts, and CI commands that exit cleanly.

I checked the current official Vitest documentation on June 3, 2026: Getting Started, Mocking, Timers, Dates, Test Environment, Coverage, Snapshot, and CLI. The docs describe Vitest 4.x, recommend Node 20 or newer, and distinguish watch mode from vitest run, which matters for CI.

Use Claude Code as a testing partner, not just a code generator. Tell it which boundary should be mocked, which clock should be fixed, whether jsdom is enough, and which command should prove the result. For adjacent workflows, read Claude Code testing strategies, MSW API mock guide, and Playwright E2E testing.

flowchart TD
  A["Spec: success and failure cases"] --> B["Vitest config: node/jsdom/coverage"]
  B --> C["Unit tests: pure logic and API boundaries"]
  B --> D["Time tests: fake timers and fixed Date"]
  B --> E["DOM tests: jsdom and snapshots"]
  C --> F["CI: vitest run --coverage"]
  D --> F
  E --> F

Start With a Stable Config

Install Vitest, the V8 coverage provider, jsdom, and TypeScript. A Vite app can share Vite config, but a dedicated vitest.config.ts makes test intent easier for Claude Code and reviewers to understand.

npm install -D vitest @vitest/coverage-v8 jsdom typescript
{
  "scripts": {
    "test": "vitest",
    "test:run": "vitest run",
    "coverage": "vitest run --coverage"
  }
}
// vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    environment: "node",
    globals: false,
    restoreMocks: true,
    coverage: {
      provider: "v8",
      reporter: ["text", "html"],
      include: ["src/**/*.{ts,tsx}"],
      exclude: ["src/**/*.d.ts", "src/**/*.test.{ts,tsx}", "src/test/**"],
      thresholds: {
        lines: 80,
        functions: 80,
        branches: 75,
        statements: 80,
      },
    },
  },
});

globals: false keeps imports explicit. That makes generated tests easier to move between files. restoreMocks: true is useful, but it does not reset fake timers or clean the DOM; those still need explicit cleanup.

Use Case 1: Mock an API Boundary

Unit tests should not call a real payment, order, or user API. Test the contract you own: path, payload, input validation, and error translation.

// src/orders.ts
export type ApiClient = {
  post<T>(path: string, body: unknown): Promise<T>;
};

export class OrderError extends Error {
  constructor(message = "Order request failed") {
    super(message);
    this.name = "OrderError";
  }
}

type OrderInput = {
  sku: string;
  quantity: number;
};

type OrderResponse = {
  id: string;
  status: "accepted" | "queued";
};

export async function createOrder(api: ApiClient, input: OrderInput) {
  if (input.quantity < 1) {
    throw new OrderError("Quantity must be at least 1");
  }

  try {
    return await api.post<OrderResponse>("/orders", input);
  } catch {
    throw new OrderError("Order API failed");
  }
}
// src/orders.test.ts
import { describe, expect, it, vi } from "vitest";
import { createOrder, type ApiClient, OrderError } from "./orders";

describe("createOrder", () => {
  it("posts the order payload to the API", async () => {
    const api: ApiClient = {
      post: vi.fn().mockResolvedValue({ id: "ord_1", status: "accepted" }),
    };

    await expect(createOrder(api, { sku: "book-1", quantity: 2 })).resolves.toEqual({
      id: "ord_1",
      status: "accepted",
    });
    expect(api.post).toHaveBeenCalledWith("/orders", { sku: "book-1", quantity: 2 });
  });

  it("rejects invalid quantity before calling the API", async () => {
    const api: ApiClient = { post: vi.fn() };

    await expect(createOrder(api, { sku: "book-1", quantity: 0 })).rejects.toBeInstanceOf(
      OrderError,
    );
    expect(api.post).not.toHaveBeenCalled();
  });

  it("wraps transport errors in a domain error", async () => {
    const api: ApiClient = {
      post: vi.fn().mockRejectedValue(new Error("ECONNRESET")),
    };

    await expect(createOrder(api, { sku: "book-1", quantity: 1 })).rejects.toThrow(
      "Order API failed",
    );
  });
});

This dependency-injection style is often simpler than module mocking. vi.mock() is still useful, but Vitest hoists it before imports, so misplaced setup can confuse beginners and AI-generated tests. Use the smallest fake that proves the behavior.

Use Case 2: Freeze Time With Fake Timers

Subscription trials, retries, notifications, and debounce logic become flaky when tests wait for real time. Vitest fake timers let you control setTimeout, setInterval, and the system date.

// src/trial.ts
const DAY_MS = 24 * 60 * 60 * 1000;

export function getTrialEndsAt(days = 7) {
  return new Date(Date.now() + days * DAY_MS).toISOString();
}

export function scheduleTrialReminder(send: () => void, days = 7) {
  return setTimeout(send, days * DAY_MS);
}
// src/trial.test.ts
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getTrialEndsAt, scheduleTrialReminder } from "./trial";

describe("trial reminder", () => {
  beforeEach(() => {
    vi.useFakeTimers();
    vi.setSystemTime(new Date("2026-06-03T00:00:00.000Z"));
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it("calculates the trial end date from the fixed clock", () => {
    expect(getTrialEndsAt()).toBe("2026-06-10T00:00:00.000Z");
  });

  it("runs the reminder after the configured number of days", () => {
    const send = vi.fn();
    const timer = scheduleTrialReminder(send, 3);

    vi.advanceTimersByTime(3 * 24 * 60 * 60 * 1000 - 1);
    expect(send).not.toHaveBeenCalled();

    vi.advanceTimersByTime(1);
    expect(send).toHaveBeenCalledTimes(1);
    clearTimeout(timer);
  });
});

The failure mode to watch is timer leakage. If vi.useRealTimers() is missing, unrelated tests may inherit the fake clock. For code that combines timers and promises, consider vi.runAllTimersAsync(), but first reduce the surface area so a single test does not mix every async mechanism.

Use Case 3: Test DOM Structure With jsdom and Snapshots

jsdom emulates browser APIs inside Node. It is good for DOM structure, text, and accessibility attributes. It is not a full browser replacement for layout, canvas, real focus behavior, or visual regressions.

// src/notice.ts
export function renderNotice(target: HTMLElement, message: string) {
  target.innerHTML = "";

  const notice = document.createElement("p");
  notice.setAttribute("role", "status");
  notice.dataset.testid = "notice";
  notice.textContent = message;

  target.append(notice);
  return notice;
}
// src/notice.test.ts
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import { renderNotice } from "./notice";

afterEach(() => {
  document.body.innerHTML = "";
});

describe("renderNotice", () => {
  it("renders an accessible status message", () => {
    document.body.innerHTML = '<div id="app"></div>';
    const target = document.querySelector<HTMLDivElement>("#app");
    if (!target) throw new Error("missing #app");

    const notice = renderNotice(target, "Saved");

    expect(notice.getAttribute("role")).toBe("status");
    expect(notice.textContent).toBe("Saved");
    expect({
      html: document.body.innerHTML,
      text: notice.textContent,
    }).toMatchInlineSnapshot(`
      {
        "html": "<div id=\\"app\\"><p role=\\"status\\" data-testid=\\"notice\\">Saved</p></div>",
        "text": "Saved",
      }
    `);
  });
});

Snapshots are useful when they are small. Avoid snapshotting a whole page or a large class-heavy component. Assert critical attributes directly, then use an inline snapshot for the compact structure that should not drift.

Coverage and CI

Coverage should reveal untested branches, not reward meaningless assertions. Vitest supports V8 and Istanbul coverage providers, and the docs show V8 as the default provider. Add coverage.include; otherwise files never imported by tests can disappear from the report.

# .github/workflows/vitest.yml
name: vitest

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm run test:run
      - run: npm run coverage

Use vitest run in CI. Plain vitest can enter watch mode in interactive contexts. For larger repositories, investigate vitest related --run or sharding after the basic full-suite gate is reliable. See Claude Code CI/CD setup for the broader pipeline.

Prompt Template for Claude Code

Add Vitest tests for src/orders.ts.
Only test createOrder.
Mock the external API with vi.fn(); do not make real HTTP calls.
Include success, invalid input, and transport failure cases.
Do not use fake timers or jsdom unless the code requires them.
After editing, report the expected npm run test:run command and remaining risks.

This prompt tells Claude Code the scope, fake boundary, required failure cases, and proof command. Put the same rules in CLAUDE.md best practices so future sessions do not stop at a happy-path test.

Failure Modes to Catch Early

Failure modeSymptomFix
Mocks are not restoredCall counts or fake implementations leakUse restoreMocks, vi.clearAllMocks(), or vi.restoreAllMocks() intentionally
Fake timers are not resetTime tests fail in a different fileAlways call vi.useRealTimers() in afterEach
jsdom is treated as a real browserCSS, layout, image, or canvas behavior differsKeep DOM contract tests in Vitest; use Playwright for browser behavior
Snapshots are too broadReviews become noisySnapshot small structures and assert important attributes directly
coverage.include is missingUntested files are invisibleInclude src/**/*.{ts,tsx} explicitly
Async code is not awaitedFalse positives passUse await expect(promise).resolves or rejects
CI uses watch modeThe job never exitsUse vitest run or vitest related --run

If you want to adapt this workflow to your repository, ClaudeCodeLab offers Claude Code training and practical templates for team testing standards, review prompts, and CI gates.

Result

The practical result is a small Vitest setup with three copy-pasteable use cases: API boundary tests, fixed-time tests, and jsdom rendering tests with a small snapshot. I also reviewed the official Vitest docs, internal links, external links, code fences, updatedDate, coverage settings, and the CI command choice before publishing this article group.

#Claude Code #Vitest #testing #TypeScript #quality assurance
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.