Advanced (Updated: 7/22/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.

On July 22, 2026, this article was checked against the official Vitest CLI guide, watch configuration, and Vitest 4 migration guide. Vitest 4 requires Vite 6 or newer and Node 20 or newer. In CI or a non-interactive terminal, plain vitest switches to run mode automatically; explicit vitest run still makes package scripts unambiguous.

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 explicit vitest run in CI so the script states its intent. Plain vitest watches only when CI is false and stdin is interactive; it switches to run mode in CI or non-interactive terminals. For interactive tools such as lint-staged, vitest related --run can test changed dependencies once. 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.

Three real-world use cases

Use case 1 is an order or signup form where a bad request can become a lost sale. Let Claude Code write the vi.fn() API boundary tests for success, invalid quantity, and transport failure. A human should approve the payment, billing, inventory, and customer-data rules before any production change.

Use case 2 is a reminder workflow for trials, appointments, or renewals. Claude Code can freeze Date, advance fake timers, and prove that the callback fires once. A human should approve timezone rules, business-day exceptions, email or SMS sending, and billing consequences.

Use case 3 is a small DOM contract such as a saved notice, validation message, or admin status banner. Claude Code can use jsdom to assert text, roles, and compact snapshots. A human should approve real-browser behavior, focus movement, visual regressions, and any customer-facing copy.

What Claude Code can do and what humans approve

AreaSafe to delegate to Claude CodeHuman approval required
Test codeDraft mocks, fake timers, jsdom assertions, and coverage commandsConfirm the assertions still protect the product behavior
CISuggest vitest run and vitest run --coverage gatesEnable required checks, billing-sensitive runners, or production deploy rules
DataUse synthetic IDs such as ord_1 and book-1Review customer data, secrets, API keys, and payment identifiers
Browser behaviorKeep DOM contracts in VitestVerify real browser flows with Playwright or manual review

Pitfalls and fixes

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
An interactive runner enters watch modeThe process waits for changesUse vitest run; use vitest related --run for changed files

The correction is not “make the test green.” When a test fails, paste the full error, tell Claude Code not to delete assertions, and ask it to fix implementation or mock setup first. Security, production, billing, and customer-data changes should remain outside the agent’s automatic edit scope until a human approves the diff.

To apply the same review constraints in your repository, use ClaudeCodeLab’s practical templates for test standards, review prompts, and CI gates.

Hands-on verification

On July 22, 2026, the TypeScript examples were extracted into a temporary fixture and run with Node v24.14.1 and Vitest 4.1.10. vitest run and vitest run --coverage passed 4 files and 7 tests covering the API boundary, module mock, fixed clock, timer boundary, DOM attributes, and inline snapshot. Coverage was 94.73% statements, 100% branches, 85.71% functions, and 94.73% lines. The mailer was mocked, so no real email or production API was called.

#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.