Vitest — Pitfalls¶
OWNER: marije, judith ALSO_USED_BY: antje LAST_VERIFIED: 2026-03-26 GE_STACK_VERSION: vitest ^4.0.18
Overview¶
Known failure modes when testing with Vitest v4 in GE projects. Every pitfall here was encountered in development or is a documented Vitest issue. Agents MUST read this page before writing or debugging tests.
Pitfall 1: Mock Leaks Between Tests¶
The most common Vitest bug in GE projects. A mock set in one test affects the next.
ANTI_PATTERN: Not restoring mocks after each test.
describe("service", () => {
it("test A", () => {
vi.spyOn(api, "fetch").mockResolvedValue({ data: "A" })
// ...
})
it("test B", () => {
// BROKEN — still uses mock from test A
const result = await api.fetch()
})
})
FIX: Always restore mocks in afterEach (GE setup file handles this globally).
CHECK: Tests pass individually but fail when run together.
THEN: Mock leak. Verify vi.restoreAllMocks() is in afterEach.
THEN: Check for module-level state that persists between tests.
Pitfall 2: vi.mock Hoisting Confusion¶
vi.mock calls are hoisted to the top of the file — they run before imports.
This is invisible in the source code and causes confusion.
ANTI_PATTERN: Using variables defined after vi.mock in the mock factory.
const mockUrl = "https://test.example.com" // Defined here...
vi.mock("@/lib/api", () => ({
baseUrl: mockUrl, // BROKEN — mockUrl is undefined at hoist time
}))
FIX: Use vi.hoisted to define variables that mocks need.
const { mockUrl } = vi.hoisted(() => ({
mockUrl: "https://test.example.com",
}))
vi.mock("@/lib/api", () => ({
baseUrl: mockUrl, // WORKS — vi.hoisted runs before vi.mock
}))
Pitfall 3: vi.spyOn After Function Execution¶
vi.spyOn only captures calls made after the spy is set.
If a module executes code on import, the spy misses it.
ANTI_PATTERN: Spying on a function that runs at import time.
// module.ts — runs immediately on import
export const config = loadConfig()
// test.ts
import * as mod from "./module" // loadConfig() already ran
vi.spyOn(mod, "loadConfig") // Too late — call already happened
FIX: Use vi.mock for modules that execute code on import.
Pitfall 4: Timer Mocking Not Restored¶
Fake timers that are not restored break subsequent tests.
setTimeout, setInterval, Date.now() all stop working correctly.
ANTI_PATTERN: Using vi.useFakeTimers() without cleanup.
FIX: Always pair with vi.useRealTimers().
CHECK: Tests hang or timeout unexpectedly.
IF: The test or a preceding test uses fake timers.
THEN: Verify vi.useRealTimers() is called in afterEach.
Pitfall 5: Module Resolution Differences¶
Vitest resolves modules differently from the runtime bundler. Path aliases, barrel exports, and conditional exports can behave differently.
ANTI_PATTERN: Tests pass locally but fail in CI.
// Works with Vite but breaks in Vitest if alias is missing
import { Button } from "@/components/ui/button"
FIX: Ensure vitest.config.ts has the same path aliases as vite.config.ts.
CHECK: Import errors in tests that work in the app.
THEN: Compare resolve.alias between Vite config and Vitest config.
Pitfall 6: JSDOM vs Real Browser Differences¶
JSDOM does not implement all browser APIs. Tests pass in JSDOM but the feature breaks in real browsers.
Missing in JSDOM: IntersectionObserver, ResizeObserver, matchMedia, navigator.clipboard, structuredClone (older JSDOM), Web Animations API.
ANTI_PATTERN: Assuming JSDOM matches real browser behavior. FIX: Mock missing APIs in setup file.
// tests/setup.ts
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}))
global.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}))
Object.defineProperty(window, "matchMedia", {
value: vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
})),
})
CHECK: A component uses browser APIs not available in JSDOM.
IF: Vitest v4 Browser Mode is an option.
THEN: Consider running those specific tests in Browser Mode for real API coverage.
IF: Browser Mode is not set up.
THEN: Mock the API in tests/setup.ts — document what is mocked.
Pitfall 7: Spy Restoration Kills Return Values¶
After vi.restoreAllMocks(), spy variables are dead.
Calling spy.mockReturnValue() after restoration has no effect.
ANTI_PATTERN: Reusing a spy variable across tests after restoration.
const spy = vi.spyOn(mod, "fn")
it("test A", () => {
spy.mockReturnValue(1) // Works
})
// afterEach restores all mocks
it("test B", () => {
spy.mockReturnValue(2) // BROKEN — spy was restored, this is a no-op
})
FIX: Create spies inside each test, not at describe level.
it("test A", () => {
const spy = vi.spyOn(mod, "fn").mockReturnValue(1)
// ...
})
it("test B", () => {
const spy = vi.spyOn(mod, "fn").mockReturnValue(2)
// ...
})
Pitfall 8: Internal Module Calls Bypass Mocks¶
Both vi.mock and vi.spyOn only affect the exported reference.
Internal calls within the same module are not intercepted.
// math.ts
function add(a: number, b: number) { return a + b }
export function sum(nums: number[]) { return nums.reduce(add, 0) } // add() is called internally
// test.ts
vi.spyOn(mathModule, "add") // This will NOT capture calls from sum()
FIX: Refactor into separate modules, or use dependency injection.
Cross-References¶
READ_ALSO: wiki/docs/stack/vitest/index.md READ_ALSO: wiki/docs/stack/vitest/patterns.md READ_ALSO: wiki/docs/stack/vitest/coverage.md READ_ALSO: wiki/docs/stack/vitest/checklist.md