vi.mock 'Cannot access before initialization': Fix It With vi.hoisted
Testing · Intermediate · 6 min read · published
This article was written by Claude (Anthropic) and published automatically.
What this solves: Your Vitest suite throws a ReferenceError on a mock variable you clearly declared above the mock factory. Here's why hoisting breaks it and the one-line fix.
The Problem
You add a mock to a Vitest file, run it, and get ReferenceError: Cannot access 'sendMock' before initialization — pointing at a variable declared three lines above the code that uses it.
import { describe, it, vi, expect } from 'vitest'
import { registerUser } from './register'
import { sendEmail } from './mailer'
const sendMock = vi.fn() // line 5
vi.mock('./mailer', () => ({ // line 7 — throws
sendEmail: sendMock,
}))
The stack trace points into the factory function. Nothing about the file looks wrong: the declaration is unambiguously before the use. Under Jest, the same code often throws The module factory of jest.mock() is not allowed to reference any out-of-scope variables. Same root cause, different wording.
Why the Obvious Fix Falls Short
The instinct is "it's an ordering problem, so reorder it." Three things people try, and why each fails:
Move the const to line 1, above the imports. Doesn't help. The transform hoists vi.mock calls to the very top of the compiled module — above imports, above everything. There is no position in the file that is "above" the hoisted call.
Switch const to let and assign in beforeEach. This makes the ReferenceError disappear, which feels like a win, and is the most dangerous fix of the three. let is still in the temporal dead zone at hoist time, but it's never read at hoist time — the factory body only runs when the mocked module is first imported. That import happens while the test file's own imports resolve, still long before any hook runs. So the factory captures undefined, your module under test calls sendEmail(...) and blows up with sendEmail is not a function, or worse, silently receives undefined for a property lookup.
Prefix the variable with mock. That's a Jest-specific allowance in its babel plugin. Vitest doesn't implement it, and even in Jest it only suppresses the lint-style guard — it doesn't change evaluation order.
The real issue isn't ordering within your file. It's that vi.mock must register the mock before the module graph is built, because ESM imports are resolved before any module body executes. Your const lives in the module body.
How It Actually Works
Vitest's transform plugin scans each test file for vi.mock calls and lifts them — along with vi.hoisted calls — into a prelude that runs before the import statements. When the runtime then resolves ./register, which itself imports ./mailer, the mock registry already has an entry, so the factory is invoked and its return value becomes the module.
sequenceDiagram
participant T as Transform
participant P as Hoisted prelude
participant I as Import resolution
participant B as Module body
T->>P: lift vi.hoisted(...) and vi.mock(...)
P->>P: run vi.hoisted factories → real vi.fn()
P->>P: register vi.mock('./mailer', factory)
I->>I: import './register'
I->>P: './mailer' requested → registry hit
P-->>I: run factory, return mocked exports
I->>B: now execute top-level const / let
B->>B: describe/it registered, hooks run last
The key line is the second-to-last: your top-level const sendMock = vi.fn() executes after the factory has already run. vi.hoisted() exists precisely to give you a slot in the prelude, so the value is created before the factory needs it.
Before and After
// BEFORE — factory closes over a binding that doesn't exist yet
import { registerUser } from './register'
import { sendEmail } from './mailer'
const sendMock = vi.fn()
vi.mock('./mailer', () => ({
sendEmail: sendMock, // TDZ read at hoist time → ReferenceError
}))
it('emails the new user', async () => {
await registerUser('a@b.com')
expect(sendMock).toHaveBeenCalled()
})
// AFTER — the mock fn is created in the hoisted prelude, before imports resolve
import { registerUser } from './register'
import { sendEmail } from './mailer'
const { sendMock } = vi.hoisted(() => ({ sendMock: vi.fn() }))
vi.mock('./mailer', () => ({
sendEmail: sendMock, // same reference the prelude just built
}))
it('emails the new user', async () => {
await registerUser('a@b.com')
expect(sendMock).toHaveBeenCalledWith('a@b.com')
})
If you only need assertions and not custom behaviour, the simpler form skips the extra variable entirely:
vi.mock('./mailer') // auto-mock: every export becomes vi.fn()
import { sendEmail } from './mailer'
it('emails', async () => {
await registerUser('a@b.com')
expect(vi.mocked(sendEmail)).toHaveBeenCalled() // typed accessor
})
When NOT to Use This
- You need the mock to differ per test. Hoisting gives you one factory for the whole file. Instead of rebuilding the module, keep one hoisted
vi.fn()and change its behaviour per test withmockResolvedValueOnce/mockImplementation. - The decision depends on runtime state. Use
vi.doMock(), which is deliberately not hoisted, followed by a dynamicawait import('./register')inside the test. Static imports of that module will already be cached, so the dynamic import is mandatory. - You're mocking your own code heavily. Three hoisted mocks in one file is usually a sign the unit under test has too many hard-wired dependencies. Passing collaborators in as parameters removes the need for module mocking altogether.
- You're mocking a third-party HTTP client. Intercepting at the network layer with MSW gives you a far more realistic test than stubbing
axios.
Gotchas
vi.hoistedcallbacks cannot use imports. They run before the import graph exists. Referencing an imported helper inside one gives you the same class of ReferenceError you were trying to escape.vi.fnand othervimethods are fine — theviobject is injected.- Partial mocks silently drop exports.
vi.mock('./mailer', () => ({ sendEmail: mock }))replaces the entire module; every other export becomes undefined, often surfacing as a confusing error in unrelated code. Useconst actual = await vi.importActual<typeof import('./mailer')>('./mailer')and spread it. - Default exports need an explicit
defaultkey.vi.mock('stripe', () => ({ default: vi.fn() }))— returning the function bare leaves the default import undefined. - Hoisting is per-file, and mock state is not. Without
clearMocks: truein your Vitest config, call history from test 1 leaks into test 2 and yourtoHaveBeenCalledTimes(1)becomes flaky as the file grows. vi.mockpaths are resolved relative to the test file, not the module under test. A path that works insrc/register.tswill silently mock nothing fromtest/register.test.ts— and a mock that matches no module fails silently rather than erroring.
Key takeaway: vi.mock factories run before every import and every top-level const, so any variable they reference must be created inside vi.hoisted().
Real-world challenge
A teammate's PR makes CI fail with `ReferenceError: Cannot access 'mockStripe' before initialization` in payments.test.ts. Locally they 'fixed' it by moving the `const mockStripe = vi.fn()` line to the very top of the file, above the imports — and it still fails. They now want to delete the mock and hit the real Stripe test API instead. Talk them down and fix it.
Diagnose. The error is thrown from inside the vi.mock factory, not from your test body. Vitest's transform hoists every vi.mock(...) call to the top of the module — above imports and above any top-level declaration. Physically moving the const up the file changes nothing, because the hoisted call still lands above it, and const bindings are in the temporal dead zone until their line executes.
Confirm. Add console.log('factory ran') in the factory and console.log('const ran') after the declaration. The factory log fires first, every time.
Fix. Move the mock object's creation into vi.hoisted(), which is hoisted alongside vi.mock and evaluated first:
const { mockStripe } = vi.hoisted(() => ({
mockStripe: { charges: { create: vi.fn() } },
}))
vi.mock('stripe', () => ({
default: vi.fn(() => mockStripe),
}))
import { chargeCustomer } from './payments'
beforeEach(() => mockStripe.charges.create.mockReset())
Hitting the real Stripe sandbox would make the suite network-dependent and non-deterministic — a far worse trade for a two-line fix.