vi.mock hoists. Your closure variables do not.
Why a Vitest mock factory that references a stub object returns undefined, the vi.hoisted() escape hatch, and the import-order rule that keeps your mocks predictable.
Hoisting is the invisible step that makes Vitest mocks work. It is also why your factory captures undefined even though the stub is declared three lines above the mock call. Once you can see what Vitest’s compiler does to your file before it runs, every related mock weirdness collapses into one rule.
We see this pattern often enough on Mergify Test Insights that it earned its own slot in our flaky Vitest catalog. The trap looks like a closure-capture bug. It is a transform-time reorder. The fix is vi.hoisted().
What you see
import { fetchUser } from "./api";
const stubUser = { id: "u-1", name: "Rémy" };
vi.mock("./api", () => ({
fetchUser: vi.fn().mockResolvedValue(stubUser),
}));
test("returns the stub", async () => {
const u = await fetchUser();
expect(u.name).toBe("Rémy");
});
The test fails: ReferenceError: Cannot access 'stubUser' before initialization. stubUser was declared. The factory references it. The factory should return a function that resolves to stubUser. Instead the factory throws before it ever returns.
If you change the order, declare stubUser before import, nothing improves. If you wrap the factory in a synchronous IIFE, same thing. The factory keeps throwing on the first read of stubUser.
Why hoisting changes the order
vi.mock is hoisted at transform time. The Vitest compiler moves every vi.mock call to the top of the file, above all imports. The transformation is invisible: your source still reads top-to-bottom, but the executed code is:
// what Vitest actually runs
vi.mock("./api", () => ({
fetchUser: vi.fn().mockResolvedValue(stubUser), // TDZ: stubUser not yet initialized
}));
import { fetchUser } from "./api";
const stubUser = { id: "u-1", name: "Rémy" };
// ...
The factory closure captures stubUser by reference. The factory itself fires when the import is resolved, which happens before the source-order const stubUser = ... line executes. let and const bindings sit in the Temporal Dead Zone from the start of their scope until the initialization line runs, and reading them from inside the TDZ throws a ReferenceError. So the factory throws on the first read of stubUser, the mock registration fails, and you get the error message above.
The reason hoisting exists: ESM imports are evaluated before any module-level code, so to mock a module you have to register the mock before the import resolves. That requires hoisting. Vitest inherited the model from Jest, where the same trap exists.
The naive fix and why it is incomplete
vi.mock("./api", () => {
const stubUser = { id: "u-1", name: "Rémy" };
return {
fetchUser: vi.fn().mockResolvedValue(stubUser),
};
});
Move the stub inside the factory body. The factory runs at hoist time, but stubUser is now declared inside it, so the assignment happens before the return value uses it. This works.
It works only because the factory does not need to share the stub with the test body. The moment you write:
test("returns the stub", async () => {
const u = await fetchUser();
expect(u).toEqual(stubUser); // can't reach the inner stubUser
});
you need the stub in two places: inside the factory (to set up the mock) and inside the test (to assert against). You cannot share a constant from the file scope without re-introducing the hoisting bug.
The fix that holds
vi.hoisted() declares values that get hoisted alongside the mock. The compiler treats them like vi.mock: lift them to the top of the file, ahead of imports.
const { stubUser } = vi.hoisted(() => ({
stubUser: { id: "u-1", name: "Rémy" },
}));
vi.mock("./api", () => ({
fetchUser: vi.fn().mockResolvedValue(stubUser),
}));
import { fetchUser } from "./api";
test("returns the stub", async () => {
const u = await fetchUser();
expect(u).toEqual(stubUser);
});
vi.hoisted runs at the top of the file, returns the value, and the destructured variable is available everywhere — including inside the vi.mock factory and inside the test body. The stub is shared, the test is readable, and the hoisting trap is solved.
The mental model: anything the mock factory references must be hoisted. vi.hoisted is the only way to declare hoisted values from your own code.
When the mock spans multiple files
Sometimes you want a mock to apply to every test in a directory. Vitest’s setupFiles config lets you put vi.mock calls in a setup script that runs before each test file:
// vitest.config.ts
export default defineConfig({
test: {
setupFiles: ["./test/setup.ts"],
},
});
// test/setup.ts
vi.mock("./db", () => ({
query: vi.fn().mockResolvedValue([]),
}));
The hoisting rules still apply within the setup file: any closure variables the factory references must be hoisted via vi.hoisted at the top of the setup file.
How Mergify catches this before you ship
Mock-hoisting bugs are deterministic — the test fails the same way every time once you write the broken pattern. They are easy to debug locally. They become flaky when paired with another flake category that hides them: a sibling test that also uses the mocked module sometimes works, masking the underlying brokenness for runs that do not exercise the failing path.
Test Insights tracks per-test confidence on the default branch. A test that fails consistently on cold workers (first run after a deployment, fresh Vitest cache) and passes on warm workers points to module-load-order issues, of which vi.mock hoisting is the most common. The dashboard tags the cold/warm split so the hoisting category is visible.
To catch hoisting traps before they ship, point Mergify at your repo. Native plugin: @mergifyio/vitest. One npm install and you’re set.
More patterns like this
vi.mock hoisting is one of the eight patterns in the flaky-tests-in-Vitest guide. The others are variants of the same theme: defaults that optimize for speed at the cost of obvious semantics. Thread-pool state leakage, isolate: false sharing the module cache, snapshot races inside test.concurrent, fake-timer leakage between tests. The blast radius varies; the shape does not.
Once you internalize the hoisting model, every related issue (vi.spyOn not finding the export, dynamic import returning the unmocked module) becomes a one-line fix.