---
title: "vi.mock hoists. Your closure variables do not."
description: "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."
author: remy-duthu
date: 2026-05-11
category: testing
readTimeMinutes: 5
featured: false
product: test-engine
---

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 Engine](/product/test-engine) that it earned its own slot in our [flaky Vitest catalog](/learn/flaky-tests/vitest). The trap looks like a closure-capture bug. It is a transform-time reorder. The fix is `vi.hoisted()`.

## What you see

```ts
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`](https://vitest.dev/api/vi.html#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:

```ts
// 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](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Statements/let#temporal_dead_zone_tdz) 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

```ts
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:

```ts
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()`](https://vitest.dev/api/vi.html#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.

```ts
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:

```ts
// 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 Engine](/product/test-engine) 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](/discovery). 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](/learn/flaky-tests/vitest). 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.
