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

Gotchas

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.