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.
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
| Area | Safe to delegate to Claude Code | Human approval required |
|---|---|---|
| Test code | Draft mocks, fake timers, jsdom assertions, and coverage commands | Confirm the assertions still protect the product behavior |
| CI | Suggest vitest run and vitest run --coverage gates | Enable required checks, billing-sensitive runners, or production deploy rules |
| Data | Use synthetic IDs such as ord_1 and book-1 | Review customer data, secrets, API keys, and payment identifiers |
| Browser behavior | Keep DOM contracts in Vitest | Verify real browser flows with Playwright or manual review |
Pitfalls and fixes
| 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 |
| An interactive runner enters watch mode | The process waits for changes | Use 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.
Related Posts
Claude Code and Playwright E2E Testing: Practical Production Guide
Use Claude Code and Playwright for E2E, mobile screenshots, auth state, traces, selectors, and CI retries.
API Testing with Claude Code: Practical Automation Guide
Practical API testing with Claude Code: smoke tests, auth, JSON shape, contract tests, CI, and safe examples.
Claude Code TDD Guide: Test-Driven Development with Vitest and node:test
Practice TDD with Claude Code using working Vitest and node:test examples, CI, hooks, and prompt templates.
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 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.