Claude Code Vitest Advanced Testing Guide
Practical Vitest guide for Claude Code: mocks, fake timers, jsdom, coverage, snapshots, and CI.
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 mode | Symptom | Fix |
|---|---|---|
| Mocks are not restored | Call counts or fake implementations leak | Use restoreMocks, vi.clearAllMocks(), or vi.restoreAllMocks() intentionally |
| Fake timers are not reset | Time tests fail in a different file | Always call vi.useRealTimers() in afterEach |
| jsdom is treated as a real browser | CSS, layout, image, or canvas behavior differs | Keep DOM contract tests in Vitest; use Playwright for browser behavior |
| Snapshots are too broad | Reviews become noisy | Snapshot small structures and assert important attributes directly |
coverage.include is missing | Untested files are invisible | Include src/**/*.{ts,tsx} explicitly |
| Async code is not awaited | False positives pass | Use await expect(promise).resolves or rejects |
| CI uses watch mode | The job never exits | Use 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.
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.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Permission Receipt Pattern: Record Scope, Proof, and Rollback
A permission receipt pattern for Claude Code: allowed actions, approval boundaries, proof commands, rollback, and revenue CTA checks.
Safe Agent Harness Design for Claude Code and Codex: Permissions, Checks, and Rollback
Build a practical agent harness for Claude Code and Codex with policy, planning, verification, and recovery layers.
Claude Code Subagents: A Practical Guide to Safe Agent Delegation
Claude Code subagent guide for safe parallel article and code work: delegation rules, prompts, pitfalls, and checks.
Related Products
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.