"vi.mock runs before your imports. Understand that, and the rest is mechanical."
Why Module Mocks Exist
Sometimes the dependency you want to fake isn't a parameter you can pass in — it's a module the code under test imports directly. fetchUserById imports ./api, which imports fetch, which talks to the real network. You don't want to thread a fake all the way through five layers; you want to say "replace ./api for this test file."
vi.mock('./api') does that. Without a factory, it replaces every export with an auto-mock (each function becomes a vi.fn()). With a factory, you control the replacement explicitly.
The Hoisting Trap
Vitest transforms your test file at load time and physically moves every top-level vi.mock call to the very top — before any imports. This is necessary because the imports need to see the mock, not the real module. The price is that the factory function passed to vi.mock runs before any code in your file. It cannot reference variables, helpers, or constants from the test file. They don't exist yet.
If you need to share data between the mock factory and the test body — say, a captured spy you want to inspect later — use vi.hoisted to declare that data alongside the mock, also hoisted, also early.
Closing over a test-file variable inside vi.mock will fail with a confusing error. The message is usually something like "Cannot access 'foo' before initialization" — that's the hoisted factory reaching for a name that hasn't been bound yet. The fix is vi.hoisted, not beforeAll.
Partial Mocks — Keep Most, Replace Some
When a module has ten exports and you only want to replace one, use vi.importActual inside the factory. It returns the original module; you spread it and override just what you need. This is the right move for utility modules — replacing now() while keeping the other 14 functions intact.
Per-Test Override with mockResolvedValueOnce
Module mocks are file-scoped by default — they apply to every test in the file. If you need different return values per test, the mock'd function (now a vi.fn()) supports the usual queue methods. Get a typed reference to it with vi.mocked(realImport) and the type checker will help you.
Always type your mocks.vi.mocked(fetchUser).mockResolvedValueOnce({ id: 1 }) gives you autocomplete on the return shape. Untyped mocks silently let you return garbage, and the test passes against the garbage instead of catching the bug.
Code
The code under test — service that imports an API module·typescript
// src/services/api.ts
export async function fetchUserById(id: number) {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) throw new Error(`User ${id} not found`);
return response.json();
}
// src/services/user-service.ts
import { fetchUserById } from './api';
export async function getDisplayName(id: number) {
const user = await fetchUserById(id);
return `${user.firstName} ${user.lastName}`;
}
Auto-mock with `vi.mocked` for typed access·typescript
import { describe, it, expect, vi } from 'vitest';
import { getDisplayName } from './user-service';
import { fetchUserById } from './api';
// Hoisted to the top — runs before the imports above.
vi.mock('./api', () => ({
fetchUserById: vi.fn(),
}));
// Get a typed handle to the mock.
const mockedFetch = vi.mocked(fetchUserById);
describe('getDisplayName', () => {
it('combines first and last name', async () => {
mockedFetch.mockResolvedValueOnce({ firstName: 'Pippa', lastName: 'Choi' });
await expect(getDisplayName(1)).resolves.toBe('Pippa Choi');
expect(mockedFetch).toHaveBeenCalledWith(1);
});
it('propagates fetch errors', async () => {
mockedFetch.mockRejectedValueOnce(new Error('User 999 not found'));
await expect(getDisplayName(999)).rejects.toThrow('User 999 not found');
});
});
Partial mock — `importOriginal` lets you keep the rest·typescript
// Partial mock — replace one export, keep the rest
import { describe, it, expect, vi } from 'vitest';
import { formatTimestamp, now } from './time-utils';
vi.mock('./time-utils', async (importOriginal) => {
const actual = await importOriginal<typeof import('./time-utils')>();
return {
...actual,
now: vi.fn(() => new Date('2026-01-01T00:00:00Z').getTime()),
};
});
describe('formatTimestamp', () => {
it('uses the mocked now() but real formatter', () => {
// formatTimestamp uses now() internally, which is mocked.
// The actual formatter logic still runs.
expect(formatTimestamp()).toBe('2026-01-01T00:00:00.000Z');
});
});
`vi.hoisted` — the escape hatch for shared data·typescript
// The hoisting trap and its fix
import { describe, it, expect, vi } from 'vitest';
// ❌ This fails — `mockUser` doesn't exist when the factory runs.
// const mockUser = { id: 1, name: 'Pippa' };
// vi.mock('./api', () => ({
// fetchUserById: vi.fn().mockResolvedValue(mockUser), // ReferenceError
// }));
// ✅ Use vi.hoisted to declare data that the factory can reference.
const { mockUser, mockFetch } = vi.hoisted(() => ({
mockUser: { id: 1, name: 'Pippa' },
mockFetch: vi.fn().mockResolvedValue({ id: 1, name: 'Pippa' }),
}));
vi.mock('./api', () => ({
fetchUserById: mockFetch,
}));
describe('with vi.hoisted', () => {
it('shares data between the mock factory and the test', async () => {
expect(mockFetch).toBeDefined();
expect(mockUser.id).toBe(1);
});
});
Write a getUserGreeting(id) function that calls fetchUserById(id) from ./api and returns Hello, ${user.firstName}!. Mock the module two ways: (1) with an auto-mock and per-test mockResolvedValueOnce, (2) with a factory that returns a custom implementation based on the id (id === 1 returns Pippa, id === 2 returns Dad). Run both, see them pass, then introduce a closure-over-variable bug deliberately and watch the hoisting error.
Hint
If the error message says 'Cannot access X before initialization,' you closed over something from test-file scope. Either inline the value into the factory, or move it into vi.hoisted.
Progress
Progress is local-only — sign in to sync across devices.