Debug vi.mock Hoisting Surprises in Vitest
Understand why vi.mock runs before your imports, why factory closures capture stale variables, and how to reference live values correctly.
Task
Diagnose a mock that returns undefined or throws at import time, explain the hoisting mechanics, and restructure the test to work with them.
When to use this
A vi.mock factory references a variable declared later in the file. The mock silently returns undefined, the test passes against a hollow double, and the bug ships.
Prerequisites
- Vitest 1.x or 2.x with vi.mock available
- A module under test with at least one import side effect
- Reproduction freedom in a scratch test file
Steps
1. Reproduce the hollow mock with a minimal file.
// user.test.ts
import { vi, it, expect } from 'vitest';
import { getUser } from './user';
const FAKE = { id: 1, name: 'amy' }; // declared AFTER vi.mock runs
vi.mock('./db', () => ({ // hoisted above imports
query: () => FAKE, // captures nothing: FAKE undefined
}));
it('returns the user', () => {
expect(getUser(1)).toEqual(FAKE); // FAILS: got undefined
});
Expected output: The test fails with undefined, proving the factory ran before FAKE existed, the signature symptom of hoisting capture.
2. Confirm execution order with console markers.
console.log('A: file body starts');
vi.mock('./db', () => { console.log('B: factory runs'); return { query: () => ({ id: 1 }) }; });
import { getUser } from './user';
console.log('C: after imports');
// stdout order: B, A, C <-- factory first, always
Expected output: The log order shows B before A: the factory executes above the import statement, before any file-level code.
3. Fix the closure by deferring the value read.
// fix 1: lazy reference via helper
const state: { fake: unknown } = { fake: null };
vi.mock('./db', () => ({
query: () => state.fake, // read at CALL time, not hoist time
}));
const FAKE = { id: 1, name: 'amy' };
beforeEach(() => { state.fake = FAKE; });
// fix 2: vi.hoisted for values the factory truly needs
const { fakeUser } = vi.hoisted(() => ({ fakeUser: { id: 1, name: 'amy' } }));
vi.mock('./db', () => ({ query: () => fakeUser }));
Expected output: Both tests now pass: the mock returns the declared value either by reading lazily at call time or by hoisting the value alongside the factory.
4. Mock at the right boundary when the target has side effects.
// side-effectful module: connects on import
// db.ts: export const pool = createPool(url); (runs at import)
// mocking ./user instead lets ./db stay real (wrong layer)
vi.mock('./user', ...)
// mocking ./db kills the import side effect (right layer)
vi.mock('./db', () => ({ pool: { query: vi.fn() } }));
Expected output: Importing the test file no longer triggers a real database connection, and the failure mode changes from a hang to a controllable assertion.
5. Verify the mock is actually called, not just defined.
import { vi, it, expect } from 'vitest';
import { query } from './db';
vi.mock('./db', () => ({ query: vi.fn(() => ({ id: 1 })) }));
it('mock is wired into the import graph', () => {
expect(vi.mocked(query)).toBeDefined();
getUser(1);
expect(vi.mocked(query)).toHaveBeenCalledTimes(1); // proves wiring
});
Expected output: The call-count assertion passes, proving the double is actually in the module graph and the test is not passing against a hollow object.
Troubleshooting
Problem
Mock factory throws is not a function at import time
Solution
The factory referenced an outer helper that is not yet initialized; move the helper into vi.hoisted or inline it in the factory.
Problem
Partial mocking breaks the rest of the module
Solution
Use importOriginal inside the factory to spread real exports and override only what the test needs, keeping defaults and helpers intact.
Problem
The same mock works in one file and not another
Solution
Module specifier resolution differs: vi.mock must use the exact specifier the test file imports, including relative paths as written there, not a normalized absolute path.
Limitations
- vi.mock cannot intercept dynamic imports resolved after test start in every case; prefer static imports at boundaries you mock.
- Hoisting is a Vitest convenience with sharp edges; a plain dependency-injection seam avoids the mechanism entirely for critical modules.
Hoisting means the factory runs before your code exists
vi.mock is hoisted above every import so the doubles exist before the module graph loads. The cost is closure semantics: anything the factory references from file scope is captured before initialization, so it reads as undefined. Recognizing the order makes the symptom obvious instead of mysterious.
// What the runner actually executes:
vi.mock('./db', () => ({ query: () => FAKE })); // 1. hoisted first
import { getUser } from './user'; // 2. imports (mock applies)
const FAKE = { id: 1 }; // 3. far too late
// The two real fixes:
const { FAKE } = vi.hoisted(() => ({ FAKE: { id: 1 } }));
vi.mock('./db', () => ({ query: () => FAKE }));
// or read lazily:
const state = { fake: null as unknown };
vi.mock('./db', () => ({ query: () => state.fake }));
The hollow-double test is the dangerous one
A mock returning undefined often makes assertions trivially true or the test fail with a confusing type error. The real hazard is the case where the test passes against a hollow object: it reports green while exercising nothing. Always assert the double was called at least once.
Mock the boundary, not the convenience
Choosing which module to mock is an architecture decision. Mocking ./db kills its import-time connection pool; mocking ./user leaves the pool live and hangs the test. The boundary with side effects is the one to double, and that insight outlives the specific framework.