Claude Code with Vitest: Mocks, Coverage, Modern Testing
Why Vitest needs a project-specific CLAUDE.md
Vitest is the test runner that finally feels native to TypeScript. It shares the Vite config, runs ESM without transformer gymnastics, and ships an API close enough to Jest that most existing tests port over with a sed pass. By 2026 it is the default choice for new Vite, SvelteKit, Nuxt, Astro, and modern Next.js projects.
That popularity is also the trap. Claude Code has read every Vitest tutorial, every Jest-to-Vitest migration post, and every in-source testing experiment from 2022 onward. Without a project-specific CLAUDE.md, the default tests Claude writes blend Jest idioms (jest.fn, done callbacks), early Vitest patterns that have since changed, and config keys borrowed from sample repos that no longer apply.
The result looks correct on first read. It compiles, the tests run, half of them pass. Then a fake timer leaks into the next file. A vi.mock factory references a top-level variable and explodes at hoist time. A snapshot captures a randomised ID and starts failing on CI two days later. The fix is upstream: tell Claude exactly which version, which patterns, and which idioms your project uses, and the failure class disappears.
This guide covers the CLAUDE.md rules that make Claude Code reliable for Vitest. If you are new to the broader workflow, the Claude Code setup guide covers installation, and the Claude Code testing guide covers test strategy across runners. For the Jest counterpart, Claude Code with Jest covers the same ground with Jest-specific idioms.
The Vitest CLAUDE.md template
The CLAUDE.md at your project root is read before every Claude Code session. For Vitest, it needs to answer: which version, which environment, mocking conventions, coverage provider, in-source testing policy, and async patterns. A flat list of rules outperforms a long paragraph because Claude reaches for the rules during code generation rather than during review.
# Vitest project rules
## Stack
- Vitest: 3.x
- Vite: 6.x (test config inherits from vite.config.ts via mergeConfig)
- TypeScript: 5.6.x with strict mode and verbatimModuleSyntax
- Module system: ESM (type: "module" in package.json)
- Node: 22.x (works with Node 20, prefer 22)
- Testing libraries: @testing-library/react 16.x, @testing-library/jest-dom 6.x
- Coverage provider: v8 (never istanbul unless explicitly required)
- Test environment: jsdom for component tests, node for unit/integration
## Project structure
- src/: application source
- src/**/*.test.ts: co-located unit tests next to source files
- src/__tests__/: cross-cutting integration tests by feature
- vitest.config.ts: single config, workspace file only for monorepos
- vitest.setup.ts: global setup (matchers, environment, polyfills)
- In-source tests: allowed only in src/lib/ utilities, guarded by import.meta.vitest
## Vitest syntax rules
- Use describe/it, never describe/test, for consistency across the codebase
- Co-locate unit tests next to source files. Integration tests live in __tests__/
- One assertion per it block where possible. Multi-assertion blocks need a comment
- Always use async/await for async tests. NEVER use done callbacks
- NEVER call vi.useFakeTimers() without a matching vi.useRealTimers() in afterEach
- NEVER use vi.runAllTimers() in tests with pending microtasks. Use vi.runAllTimersAsync()
## Mocking rules
- vi.mock() for entire modules at the top of the file (hoisted by Vitest)
- vi.spyOn() for individual methods on existing objects (preserves original)
- NEVER reference top-level variables inside a vi.mock factory. Use vi.hoisted() if needed
- Reset mocks between tests: clearMocks: true in vitest.config.ts (already set)
- For React components, prefer @testing-library/react user events over vi.fn() handlers
- NEVER mock React, react-dom, @testing-library/*, or the framework runtime
## Coverage thresholds (v8 provider)
- Lines: 80%
- Functions: 80%
- Branches: 75%
- Statements: 80%
- Excluded from coverage: src/types/, src/generated/, *.stories.tsx, *.config.ts, **/__tests__/**
## Hard rules
- NEVER commit a snapshot file that contains unstable values (Date, random IDs, hashes)
- NEVER use --bail=1 in CI to mask flaky tests. Find and fix the flake
- NEVER skip a failing test with .skip or it.skip without a linked issue in a comment
- NEVER assert implementation details (private methods, internal state, hook call order)
- NEVER write a test that depends on test execution order
Three of those rules carry disproportionate weight.
The mocking rule matters because vi.mock and vi.spyOn solve different problems, and Vitest's hoisting behaviour is unforgiving. vi.mock(path, factory) is hoisted to the top of the file before any imports execute, so the factory cannot reference variables defined later. Claude regularly writes vi.mock('./foo', () => ({ default: mockValue })) where mockValue is declared at the top of the test file. The hoist runs first, mockValue is undefined, and the test crashes with a confusing reference error. The rule forces Claude toward vi.hoisted() or an inline factory, which both work correctly.
The fake timer rule kills the largest class of cross-file flakiness. Vitest, like Jest, shares state between tests within a file. If vi.useFakeTimers() runs in one test and vi.useRealTimers() never runs, every subsequent test in that file inherits fake timers. Worse, a test that uses real setTimeout will hang because the timer never fires. Pairing useFakeTimers with useRealTimers in afterEach is the only reliable cure.
The execution order rule prevents the most insidious failures. Vitest parallelises files by default and randomises test order within files when sequence.shuffle is enabled. Tests that assume a fixture from the previous test pass in development, fail intermittently in CI, and produce log output that looks like a transient network error. Forbidding order-dependent tests at the CLAUDE.md level keeps Claude from generating them.
For TypeScript-specific patterns, the Claude Code TypeScript guide covers tsconfig strictness rules that pair with Vitest's verbatimModuleSyntax requirement. For Vite-native setup, Claude Code with Vite covers the host config that Vitest extends.
vitest.config.ts patterns that work
The single biggest advantage of Vitest over Jest is that the test config is a Vite config. The same plugins, the same resolve aliases, the same module graph. The trap is that Claude often writes a standalone vitest.config.ts that duplicates Vite settings instead of merging from the host config, and the two drift over time.
A clean pattern uses mergeConfig to inherit everything from vite.config.ts and add only the test-specific keys.
// vitest.config.ts
import { defineConfig, mergeConfig } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
defineConfig({
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],
include: [
'src/**/*.{test,spec}.{ts,tsx}',
'src/**/__tests__/**/*.{ts,tsx}',
],
exclude: [
'node_modules',
'dist',
'.next',
'e2e/**',
],
clearMocks: true,
mockReset: false,
restoreMocks: false,
testTimeout: 10_000,
hookTimeout: 10_000,
pool: 'threads',
poolOptions: {
threads: {
singleThread: false,
minThreads: 1,
maxThreads: 4,
},
},
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
reportsDirectory: './coverage',
include: ['src/**/*.{ts,tsx}'],
exclude: [
'src/**/*.d.ts',
'src/**/*.stories.{ts,tsx}',
'src/**/*.test.{ts,tsx}',
'src/types/**',
'src/generated/**',
'src/**/index.ts',
'src/**/*.config.ts',
],
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
},
all: true,
skipFull: false,
},
sequence: {
shuffle: false,
concurrent: false,
},
typecheck: {
enabled: false,
tsconfig: './tsconfig.test.json',
},
},
}),
)
A few elements are worth highlighting because Claude consistently gets them wrong without guidance.
globals: true exposes describe, it, expect, and friends as globals so tests do not need to import them. It is a preference, not a requirement. If your codebase prefers explicit imports, set globals: false and add import { describe, it, expect } from 'vitest' to your CLAUDE.md as a rule.
The coverage.provider: 'v8' line is the load-bearing default. The v8 provider uses Node's built-in coverage collection. It is faster, requires no source instrumentation, and produces accurate line and function counts. The alternative, istanbul, instruments code at transform time, requires a separate transform pipeline that fights with Vite, and produces subtly different counts. New projects should use v8.
coverage.all: true includes files that have no tests in the coverage report. Without it, a file with zero coverage is silently absent from the percentage calculation, which inflates the apparent coverage. Claude defaults to all: false to keep reports short. Override it.
The pool: 'threads' setting is the default in Vitest 3.x. Worker threads share memory efficiently but cannot run code that requires process isolation (notably, modules that touch process.env during import). If you see EAGAIN errors or module state bleed, switch to forks.
sequence.shuffle: false keeps tests in source order within a file. Set it to true once your test suite is mature enough to handle randomisation. typecheck.enabled: false is the right default; turn it on only when you have type-level .test-d.ts tests.
For environment variables, configure them in vitest.setup.ts rather than .env.test. Vitest reads .env files through Vite's loader, but the loader's behaviour around test mode is subtle. An explicit setup file removes the ambiguity.
Mocking conventions: vi.mock vs vi.spyOn
Vitest's mocking API mirrors Jest's at the surface level. Underneath, the hoisting and module-resolution model is different enough that Jest-trained intuition produces broken tests. The CLAUDE.md rule needs to be specific about when to use each tool.
vi.mock(path, factory?) replaces an entire module before any imports execute. Vitest hoists the call to the very top of the file, including above any import statements you wrote. The factory function runs in isolation. It cannot reference any variable defined in the test file body unless that variable is wrapped in vi.hoisted().
// src/services/userService.test.ts
import { describe, it, expect, vi } from 'vitest'
import { fetchUser } from './userService'
import { apiClient } from '../api/client'
const { mockGet } = vi.hoisted(() => ({
mockGet: vi.fn(),
}))
vi.mock('../api/client', () => ({
apiClient: {
get: mockGet,
},
}))
describe('fetchUser', () => {
it('returns user data on success', async () => {
mockGet.mockResolvedValue({
data: { id: '1', name: 'Hugo' },
})
const user = await fetchUser('1')
expect(mockGet).toHaveBeenCalledWith('/users/1')
expect(user).toEqual({ id: '1', name: 'Hugo' })
})
})
vi.hoisted is the escape hatch for sharing references between the factory and the test body. It hoists the wrapped function alongside the vi.mock call so the references are available at hoist time. Without it, the factory closes over a name that does not exist yet and the test fails with ReferenceError: Cannot access 'mockGet' before initialization. This is one of the most common Vitest bugs Claude generates without supervision.
vi.spyOn(object, methodName) wraps a single method on an existing object and preserves the original implementation by default. Use it when you want to observe a real call without replacing it.
// src/services/analytics.test.ts
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { MockInstance } from 'vitest'
import { analytics } from './analytics'
import { logger } from '../utils/logger'
describe('analytics', () => {
let logSpy: MockInstance
beforeEach(() => {
logSpy = vi.spyOn(logger, 'info').mockImplementation(() => undefined)
})
afterEach(() => {
logSpy.mockRestore()
})
it('logs the event with payload', () => {
analytics.track('signup', { plan: 'pro' })
expect(logSpy).toHaveBeenCalledWith(
'analytics:track',
expect.objectContaining({ event: 'signup', plan: 'pro' }),
)
})
})
mockImplementation(() => undefined) silences the real logger.info for the duration of the test. mockRestore() in afterEach puts the original implementation back, which is essential when other tests in the same file rely on real logging.
The decision rule for CLAUDE.md is the same shape as Jest's. Use vi.mock when you want to replace a whole module before any code runs (network clients, file system, external services, framework helpers). Use vi.spyOn when you want to observe or selectively override a method on an existing object. Without the rule, Claude defaults to vi.mock for everything, and tests stop verifying the integration paths they should be exercising.
For React projects, prefer rendering real components and mocking only the network or storage layer beneath them. Mocking child components hides integration bugs that only surface in real composition. Claude Code with React covers the rendering patterns that pair with Vitest in component tests.
One Vitest-specific note: vi.mock factories can return either a default-export shape or a named-export shape. Match the target module. If ../api/client exports apiClient as a named export, the factory returns { apiClient: ... }. If it exports default as the API client, the factory returns { default: ... }. Mismatched shapes produce undefined imports at runtime with no compile-time warning.
Async patterns and fake timers
Async testing in Vitest has the same four patterns as Jest, with one additional async variant for timers that Claude often misses. Pin the choices in CLAUDE.md and the failure class goes away.
Pattern 1: async/await with explicit assertions. The default for almost everything.
it('saves the draft', async () => {
const draft = await saveDraft({ title: 'Hello' })
expect(draft.id).toBeDefined()
expect(draft.title).toBe('Hello')
})
Pattern 2: expect(...).resolves and expect(...).rejects. Compact for one-liners. The leading await is mandatory; without it, the test passes regardless of whether the promise resolves or rejects.
it('resolves with the draft', async () => {
await expect(saveDraft({ title: 'Hello' })).resolves.toMatchObject({
title: 'Hello',
})
})
it('rejects when the title is empty', async () => {
await expect(saveDraft({ title: '' })).rejects.toThrow('Title is required')
})
Make await expect(...).resolves and await expect(...).rejects a hard CLAUDE.md rule. The missing await is one of the most common Claude-generated bugs and one of the most invisible: the test always passes, regardless of whether the implementation is correct.
Pattern 3: waitFor for async UI state. From @testing-library/react.
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
it('shows the welcome message after submit', async () => {
const user = userEvent.setup()
render(<SignupForm />)
await user.type(screen.getByLabelText(/email/i), 'hugo@example.com')
await user.click(screen.getByRole('button', { name: /sign up/i }))
await waitFor(() => {
expect(screen.getByText(/welcome/i)).toBeInTheDocument()
})
})
Do not wrap synchronous expect calls in waitFor. It adds polling overhead and obscures intent. Use it only for assertions that depend on a future microtask, a network round trip, or a state transition.
Pattern 4: Fake timers with the async variants. This is where Vitest pulls ahead of Jest with vi.runAllTimersAsync, vi.advanceTimersByTimeAsync, and vi.runOnlyPendingTimersAsync. The async variants flush both timers and any microtasks queued by their callbacks, which is what most real-world code needs.
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { debounce } from './debounce'
describe('debounce', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('calls fn once after rapid invocations', async () => {
const fn = vi.fn()
const debounced = debounce(fn, 200)
debounced('a')
debounced('b')
debounced('c')
await vi.advanceTimersByTimeAsync(200)
expect(fn).toHaveBeenCalledTimes(1)
expect(fn).toHaveBeenCalledWith('c')
})
})
The async timer methods solve a class of test that the synchronous methods cannot. Any code that mixes setTimeout with await requires both timer advance and microtask flush. Synchronous vi.advanceTimersByTime advances the clock but does not yield to the event loop, so promises chained on timer callbacks never resolve. Async variants do both. Make them the default in CLAUDE.md and Claude stops generating tests that hang.
Anti-patterns to forbid: done callbacks (use async/await), arbitrary setTimeout waits (use waitFor), and await new Promise(r => setImmediate(r)) to flush microtasks (the test is racing the implementation, not waiting for it). For debugging async failures, Claude Code debugging covers the diagnostic workflow.
Coverage and in-source testing
Two Vitest features deserve their own CLAUDE.md rules because Claude misuses both by default: the v8 coverage provider, and in-source testing.
Coverage thresholds. The thresholds block fails the Vitest run when coverage drops below the configured percentages. Set thresholds at the level you meet today, not aspirational 100%. A threshold you cannot meet creates a culture of skipping tests to land changes.
coverage: {
provider: 'v8',
thresholds: {
lines: 80,
functions: 80,
branches: 75,
statements: 80,
'src/payments/**': {
lines: 95,
functions: 95,
branches: 90,
statements: 95,
},
},
}
Per-glob overrides let you require higher coverage in critical paths (payments, auth, billing) than in the rest of the codebase. Claude generates the global block correctly and skips per-glob overrides unless you have an example in the file already.
The v8 provider has one footgun. Inline functions (arrow callbacks passed to .map, .filter, etc.) are counted as separate functions for the function metric. A file with one exported function and ten inline arrow callbacks shows eleven functions, all uncovered if the file is untested. The branch metric is more reliable for these cases.
In-source testing. Vitest supports putting tests in the same file as the code they exercise, gated by import.meta.vitest. It is a useful pattern for utility libraries where the test and the function are short enough to read together. It is a terrible pattern for application code because the bundler has to strip the test block and the runtime cost is non-zero.
// src/lib/slugify.ts
export function slugify(input: string): string {
return input
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
if (import.meta.vitest) {
const { describe, it, expect } = import.meta.vitest
describe('slugify', () => {
it('lowercases the input', () => {
expect(slugify('Hello World')).toBe('hello-world')
})
it('strips punctuation', () => {
expect(slugify("It's a test!")).toBe('it-s-a-test')
})
it('trims leading and trailing dashes', () => {
expect(slugify('---abc---')).toBe('abc')
})
})
}
For in-source testing to work, vitest.config.ts needs test.includeSource: ['src/**/*.{ts,tsx}'] and the production build needs define: { 'import.meta.vitest': 'undefined' } so the dead-code eliminator strips the test block. Without the define, the test block ships to production.
The CLAUDE.md rule should be tight: in-source tests are allowed only in src/lib/ for pure utility functions with no external dependencies. Everything else uses co-located *.test.ts files. The boundary keeps the production bundle clean and prevents the "all tests in one file" anti-pattern.
Snapshot tests. Vitest's snapshot behaviour matches Jest's. Snapshots are excellent for stable output (API contracts, error messages, generated SQL) and bad for unstable output (React components with timestamps, IDs, or randomised content). The CLAUDE.md rule: snapshot tests are allowed only for output that is deterministic. Component snapshots must use @testing-library/react's output, never raw render trees. Treat a snapshot diff like a code diff.
For monorepo-wide coverage strategies, Claude Code with monorepos covers Vitest workspace configuration and per-package thresholds.
Hard rules and permission hooks
Vitest commands fall into two groups: safe commands and destructive commands. The destructive set updates snapshot files, regenerates baselines, or wipes coverage outputs. Gating them in .claude/settings.local.json prevents Claude from reaching for them as flailing fixes.
{
"permissions": {
"allow": [
"Bash(pnpm test*)",
"Bash(pnpm test:unit*)",
"Bash(pnpm test:integration*)",
"Bash(pnpm vitest*)",
"Bash(pnpm vitest run*)",
"Bash(pnpm vitest --coverage*)"
],
"deny": [
"Bash(pnpm vitest -u*)",
"Bash(pnpm vitest --update*)",
"Bash(pnpm test -- -u*)",
"Bash(pnpm vitest --bail=1*)",
"Bash(rm -rf coverage*)",
"Bash(rm -rf .vitest*)"
]
}
}
The --update deny is the load-bearing one. An automated snapshot update rewrites every snapshot to match the current output, silently fixing broken snapshots that were the test, not the bug. Gating snapshot updates means Claude surfaces the change for human approval. The --bail=1 deny prevents Claude from masking flaky tests in CI by stopping at the first failure; flaky tests should be diagnosed, not deferred. The rm -rf denies prevent cache wipes from being part of a routine debug loop, where they hide real cache invalidation bugs.
For permission hook patterns across project types, Claude Code best practices covers the principles regardless of test runner. The role of CLAUDE.md across all of these is covered in CLAUDE.md explained.
A short note on Vitest vs Jest. Vitest wins on speed (parallel ESM execution, no transform overhead, native TypeScript), config simplicity (one Vite config covers app and tests), and modern API ergonomics (vi.runAllTimersAsync, in-source testing, native ESM mocks). Jest wins on ecosystem depth (a decade of plugins, mature CI integrations) and on stability for very large legacy suites where rewriting jest.mock factories into vi.hoisted calls is its own project. For new projects in 2026, Vitest is the default. Both runners are excellent; the right choice depends on existing investment.
The patterns above reduce to a list of mandatory rules that belong at the top of every Vitest CLAUDE.md:
- Use
describe/it, neverdescribe/test. Pick one and stay consistent. async/awaitfor all async tests. Neverdonecallbacks.- Always
awaitexpect(...).resolvesandexpect(...).rejects. vi.mockfor whole-module replacement.vi.spyOnfor single-method observation.- Wrap factory dependencies in
vi.hoisted(). Never reference top-level variables inside avi.mockfactory. - Match every
vi.useFakeTimers()withvi.useRealTimers()inafterEach. - Prefer
vi.advanceTimersByTimeAsyncandvi.runAllTimersAsyncwhen timer callbacks queue microtasks. - Snapshot tests only for deterministic output. Never for components with timestamps or IDs.
clearMocks: truein config. Reset call history, preserve implementations.- Coverage thresholds set to the level you meet today, not aspirational. Per-glob overrides for critical paths. Coverage provider
v8, neveristanbulfor new projects. - In-source testing only in
src/lib/for pure utilities. Everything else uses co-located*.test.tsfiles. - Never use
--bail=1to mask flake. Find and fix. - Deny
--update, snapshot rewrites, and cache wipes in permission hooks.
These rules prevent the most common Vitest failures in Claude Code sessions. They produce tests that fail when the code is wrong and pass when it is right, snapshots that catch real changes, coverage that means something, and a config that does not break the moment you add a new pool or a new environment.
Vitest is now the modern default for TypeScript and ESM projects. The runtime is stable, the API is settling, and the ecosystem of plugins and reporters has reached parity with Jest for most needs. The CLAUDE.md template above is designed to outlast the next two minor versions of Vitest because it focuses on conventions, not on which API was added last quarter. Claudify includes a Vitest-specific CLAUDE.md template pre-configured for Vitest 3.x, the v8 coverage provider, and the testing-library patterns above.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify