Avoiding vi.mock Hoisting Pitfalls
You write a mock, reference a helper variable inside its factory, and Vitest throws ReferenceError: Cannot access 'mockSend' before initialization — even though the variable is declared right above the vi.mock call. This guide is for engineers using Vitest 1.x or 2.x who keep hitting hoisting-related failures when mocking modules, and it explains the one mechanism behind all of them: vi.mock does not run where you wrote it. Vitest lifts every vi.mock call to the very top of the file, above your import statements, so the mock is registered before the module graph resolves. Once you internalize that, vi.hoisted, factory closures, and the ordering rules stop being magic and become predictable. This is a foundational technique within module and dependency mocking, and getting it wrong blocks almost every other pattern in that topic area.
Root Cause Analysis
The root cause is a deliberate design decision colliding with an intuitive but wrong mental model. Your intuition says code runs top to bottom: the const on line 3 exists by the time vi.mock runs on line 5. But ES modules hoist their import statements to the top of the module and execute them first, before any other statement. For a mock to intercept those imports, it must be registered even earlier — before the imports evaluate. Vitest achieves this with a compile-time transform that hoists every vi.mock call above all imports. The call you wrote on line 5 effectively executes on line 0.
That relocation is why a factory that references a normal file-scope const explodes. After hoisting, the vi.mock factory is defined and its registration runs before the const mockSend = vi.fn() on line 3 has been initialized. In JavaScript’s temporal dead zone, touching a let or const before its declaration executes throws ReferenceError, not undefined. So the error is not Vitest being fragile — it is standard language semantics applied to code that has been moved above the variable it depends on.
There is a second, subtler consequence. Because mock registration happens before imports resolve, the imported binding your test uses already points at the mock by the time any test body runs. This is what makes module mocking work at all, but it also means you cannot conditionally decide inside a test whether a static import is mocked — that decision was made in the hoisted phase. When you need per-test conditional mocking, you must switch to the non-hoisted vi.doMock with dynamic import(), which we cover in Troubleshooting.
Reproducible Setup
Install Vitest and create a small module with a dependency you can mock.
npm install -D vitest
// src/mailer.ts
export function send(msg: { to: string; body: string }) {
return fetch('https://mail.example.com/send', {
method: 'POST',
body: JSON.stringify(msg),
});
}
// src/notifier.ts
import { send } from './mailer';
export async function notify(to: string) {
await send({ to, body: 'Welcome aboard' });
return { queued: true };
}
The following test looks correct but throws on load — it is the canonical trap this guide exists to fix.
// src/notifier.broken.test.ts — DO NOT COPY, this throws
import { vi, it, expect } from 'vitest';
import { notify } from './notifier';
const mockSend = vi.fn(); // hoisted below vi.mock — TDZ error
vi.mock('./mailer', () => ({ send: mockSend }));
it('queues a message', async () => {
await notify('ada@example.com');
expect(mockSend).toHaveBeenCalled();
});
Implementation
Step 1 — Move shared values into vi.hoisted. vi.hoisted runs its callback in the same early phase as mock registration and returns the result to the file scope. Anything the factory needs — spies, fixtures, config — goes here, and the returned binding is safe to reference both inside the factory and inside your tests.
// src/notifier.test.ts
import { vi, it, expect, beforeEach } from 'vitest';
import { notify } from './notifier';
import { send } from './mailer';
const { mockSend } = vi.hoisted(() => ({ mockSend: vi.fn() }));
vi.mock('./mailer', () => ({ send: mockSend }));
beforeEach(() => vi.clearAllMocks());
it('queues a message through the mailer', async () => {
const result = await notify('ada@example.com');
expect(result.queued).toBe(true);
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({ to: 'ada@example.com' }),
);
});
Step 2 — Or create spies inside the factory itself. If you do not need to reference the spy by a pre-declared name, define it in the factory and reach for it through the imported binding at assertion time. Because the import already resolves to the mock, send in your test is the spy.
import { vi, it, expect } from 'vitest';
import { notify } from './notifier';
import { send } from './mailer';
vi.mock('./mailer', () => ({ send: vi.fn() }));
it('calls the mailer', async () => {
await notify('grace@example.com');
expect(vi.mocked(send)).toHaveBeenCalledOnce();
});
Step 3 — Type the mock so assertions are safe. Wrap the imported binding in vi.mocked() to recover the mock methods with full type inference, avoiding as unknown as Mock casts that hide real signature drift.
import { vi } from 'vitest';
import { send } from './mailer';
const sendMock = vi.mocked(send);
sendMock.mockResolvedValueOnce(new Response(null, { status: 202 }));
Step 4 — Keep only hoist-safe code above the mock. Never call a factory helper that depends on module-scope state initialized after the imports. If a helper builds fixtures, either declare it with vi.hoisted or inline it into the factory. Treat the factory as if it runs at line zero, because it does.
Verification
Prove the fix two ways. First, the test simply runs without a ReferenceError on import — a hoisting bug fails at load time, before any assertion, so a clean run is itself the primary signal. Second, confirm that the mock and your test observe the same spy instance by asserting on call counts after invoking the code under test.
import { vi, it, expect } from 'vitest';
import { send } from './mailer';
import { notify } from './notifier';
it('shares one spy between factory and test', async () => {
expect(vi.isMockFunction(send)).toBe(true);
await notify('lin@example.com');
expect(send).toHaveBeenCalledTimes(1);
});
If vi.isMockFunction(send) returns false, the mock never applied — usually a path mismatch between the vi.mock argument and the real import specifier. Keep those strings identical, including relative-path style, so the registry matches.
The instance-identity check matters more than it first appears. A hoisting bug does not always throw; a factory that quietly creates its own spy while the test asserts on a different variable produces a test that passes for the wrong reason or fails with a confusing zero-call count. Making the factory and the test share one vi.hoisted reference, then asserting the imported binding is that same mock, is what proves the wiring rather than merely the absence of a crash.
Troubleshooting
Cannot access variable before initialization. Diagnosis: a factory references a plain const/let declared below it in source, which sits above it after hoisting. Fix: move the value into vi.hoisted(() => ({ ... })) and destructure the result. This is the single most common failure and vi.hoisted resolves every variant of it.
The mock has no effect and the real module runs. Diagnosis: the specifier passed to vi.mock does not match the import, or the module is a pre-bundled dependency Vitest never transforms. Fix: match the exact import string; for third-party packages, add the package to server.deps.inline so Vitest processes it and the hoisted registration can intercept. This interacts with the interop rules in mocking ES modules vs CommonJS in Vitest.
I need to mock conditionally per test. Diagnosis: static vi.mock is hoisted once for the whole file, so it cannot vary between tests. Fix: use vi.doMock, which is not hoisted, followed by a dynamic await import() inside the test so the module resolves after the mock is set. Remember to vi.resetModules() between tests so the fresh import re-evaluates against the new mock.
FAQ
Why does Vitest hoist vi.mock at all instead of running it in place?
Because ES module imports are themselves hoisted and evaluated before any other statement in the file. For a mock to intercept those imports, its registration has to happen even earlier — before the imports resolve. Vitest’s transform lifts vi.mock above the imports to guarantee that ordering. Running it in place would be too late: the real module would already be bound to your import before the mock had a chance to replace it.
What exactly does vi.hoisted return and when does it run?
vi.hoisted(fn) executes fn during the same early, pre-import phase as mock registration and returns whatever fn returns to your module scope. Its purpose is to let you create values — typically vi.fn() spies or fixtures — that a vi.mock factory needs to close over, without tripping the temporal dead zone. Because it runs once, before imports, the value it produces is a stable single instance shared by the factory and every test in the file.
Do I still need vi.hoisted if I define the spy inside the factory?
No. If the factory creates the spy itself and you assert through the imported binding wrapped in vi.mocked(), you never declare a separate variable, so there is nothing to hoist. Use vi.hoisted only when you want a named reference available in both the factory and the surrounding test code, or when several mocks must share one spy.
Does the same hoisting behaviour apply to Jest’s jest.mock?
Jest also hoists jest.mock above imports using its Babel transform, so the top-level ordering trap is similar. The differences are in the escape hatches: Jest’s convention is to prefix out-of-scope variables with mock to opt them into the allowed set, whereas Vitest gives you the explicit vi.hoisted primitive. When migrating, replace the mock-prefix convention with vi.hoisted for clearer, less error-prone intent.
Related
- Back to Module & Dependency Mocking
- Partial mocking with vi.importActual — apply hoist-safe factories while keeping the real module.
- Mocking ES modules vs CommonJS in Vitest — resolve mocks that silently fail to apply to bundled packages.
- Spying on default exports in Vitest — the hoisting rules carry over when the target is a default export.
- Vitest configuration & setup — set
clearMocksandpoolso hoisted mocks never leak.