Skip to content

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

// tests/setup.ts — MUST be present
afterEach(() => {
  vi.restoreAllMocks()
})

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().

beforeEach(() => { vi.useFakeTimers() })
afterEach(() => { 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.

resolve: {
  alias: {
    "@": path.resolve(__dirname, "./src"),
  },
}

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