Module & Dependency Mocking
Module and dependency mocking replaces an entire imported module — its default export, its named exports, and the transitive dependencies it pulls in — with a controlled stand-in, so the unit under test runs against behaviour you define rather than behaviour you inherited. It is the deepest interception layer in the discipline of advanced mocking and service isolation: where HTTP stubbing swaps a network response and time control swaps the clock, module mocking swaps the code boundary itself. When a service imports a payment SDK, a logger, a feature-flag client, or a database driver, you rarely want the real thing in a unit test — you want a double that resolves synchronously, records its calls, and never touches a socket. This topic area covers how Vitest actually performs that substitution, why the mechanism looks surprising the first time it bites you, and how to wield it without the hoisting, partial-mock, and module-format pitfalls that make module mocking the single most misunderstood corner of the modern JavaScript testing stack.
The reason module mocking feels different from every other kind of mocking is that it operates on the module graph before your test code runs, not during it. vi.mock is not an ordinary function call executed top-to-bottom; it is a directive that Vitest lifts out of your file and applies while the module graph is being constructed. Understanding that single fact — that mock registration happens in a separate, earlier phase than test execution — dissolves the majority of the confusion beginners hit, and it is the thread that connects every guide below. The default stack throughout is Vitest with vi.mock, vi.importActual, vi.hoisted, and vi.spyOn; Jest equivalents are noted where the semantics differ, and MSW v2 appears only where a mocked module still needs a network boundary underneath it.
Architectural Scope & Boundaries
Module mocking belongs at the unit and lower-integration tiers of a deliberate test pyramid strategy. It is the correct tool when the dependency you want to remove is a code module — an SDK wrapper, a config loader, a logger, an analytics client, a data-access layer — rather than a transport boundary like the network or the clock. The distinction matters because choosing the wrong layer inverts your test’s fidelity. If you vi.mock the module that wraps fetch, you have stopped testing that your code assembles requests correctly; if all you wanted was to control the response, HTTP request stubbing techniques with MSW v2 keep your real client code in the loop and give you higher-fidelity coverage.
The boundary rule that keeps module mocks honest is this: mock the module you own the contract with, not the module three layers down. If your service imports a ./payments module, mock ./payments, because you control its interface and can keep the mock in sync. Mocking stripe directly — a third-party package whose surface you do not control — couples your test to an interface that can change under you and produces mocks that drift silently from reality. When you genuinely need to replace a third-party HTTP client’s behaviour, prefer a network-level interceptor so the client’s real serialization runs; module mocking is for the seams you designed.
There are three things this technique deliberately does not cover. It does not replace network responses at the transport layer — that is stubbing’s job, including the finer points of stubbing axios interceptors in Vitest. It does not fake timers or Date — that is time control. And it does not stub browser globals like IntersectionObserver or matchMedia, which are runtime environment fixtures rather than modules in your graph. Keep those responsibilities separate and each mock stays small, legible, and easy to reset.
A useful heuristic for the boundary is to ask what a failure in this test would tell you. A well-scoped module mock leaves exactly one uncontrolled variable — the code under test — so a red test points unambiguously at a change in that code. A mock that reaches too deep, replacing a module your unit only indirectly depends on, blurs that signal: the test can now fail because the mock’s assumptions diverged from reality, not because the code regressed. The four guides in this topic area are ordered so that each removes one class of ambiguity, from the hoisting mechanics that decide when a mock applies, through partial mocks that decide how much it replaces, to the interop and default-export rules that decide what shape the replacement must take.
Prerequisites
Step-by-Step Implementation
Step 1 — Register the mock with a factory. The second argument to vi.mock is a factory that returns the replacement module namespace. Return every export the consumer uses; Vitest does not merge with the real module unless you ask it to. Because the factory is hoisted above the imports, it must not close over any file-scope variable that is declared after it.
// src/notifier.test.ts
import { vi, describe, it, expect } from 'vitest';
import { sendWelcome } from './notifier';
vi.mock('./mailer', () => ({
send: vi.fn().mockResolvedValue({ id: 'msg_1', accepted: true }),
}));
describe('sendWelcome', () => {
it('dispatches through the mailer', async () => {
const result = await sendWelcome('ada@example.com');
expect(result.accepted).toBe(true);
});
});
Step 2 — Reference mocks safely through vi.hoisted. When your factory needs a shared spy that the test body also asserts on, declare it with vi.hoisted so it is created in the same early phase as the mock registration. Reaching for a plain const here is the number-one cause of “cannot access before initialization” — the full mechanism is dissected in avoiding vi.mock hoisting pitfalls.
const { sendSpy } = vi.hoisted(() => ({ sendSpy: vi.fn() }));
vi.mock('./mailer', () => ({ send: sendSpy }));
Step 3 — Keep the real module and override one export. Most mocks should be partial: preserve the real implementation and replace only the volatile function. vi.importActual loads the genuine namespace inside the factory so you can spread it and override surgically, a pattern covered end to end in partial mocking with vi.importActual.
vi.mock('./config', async (importOriginal) => {
const actual = await importOriginal<typeof import('./config')>();
return { ...actual, isProduction: () => true };
});
Step 4 — Spy on a default export. Default exports need a default key in the mock namespace, and spying on them differs from named exports because of how the interop layer materializes them. The reliable patterns live in spying on default exports in Vitest.
vi.mock('./logger', () => ({ default: { info: vi.fn(), error: vi.fn() } }));
Step 5 — Reset between tests. A mocked module persists across a file. Clear call history with vi.clearAllMocks() in beforeEach so counts start at zero, and restore original implementations with vi.restoreAllMocks() when you used vi.spyOn. Configure clearMocks: true in vitest.config.ts to make this automatic across the whole suite.
Configuration Reference
The behaviour of module mocks is governed both by the Vitest API you call and by a few config flags that decide how aggressively state is reset between tests. The table below is the practical set worth memorizing.
| Option | Where | Type | Default | Effect |
|---|---|---|---|---|
vi.mock(path, factory) |
test file | call | — | Registers a hoisted module replacement; factory returns the namespace. |
vi.hoisted(fn) |
test file | call | — | Runs fn in the hoisted phase so its values are available inside factories. |
vi.importActual(path) |
inside factory | async call | — | Loads the real module namespace for partial overrides. |
vi.doMock(path, factory) |
test body | call | — | Non-hoisted mock; applies to subsequent dynamic import() only. |
vi.spyOn(obj, key) |
test body | call | — | Wraps an existing method, preserving the original unless overridden. |
clearMocks |
config | boolean |
false |
Calls mockClear() on every mock before each test. |
restoreMocks |
config | boolean |
false |
Restores spies created by vi.spyOn before each test. |
deps.interopDefault |
config | boolean |
true |
Controls CommonJS default-export interop for imported modules. |
server.deps.inline |
config | string[] |
[] |
Forces packages through Vitest’s transform so mocks apply to them. |
The two flags that surprise teams most are clearMocks and restoreMocks. clearMocks only resets call history — it leaves your mock implementations in place — while restoreMocks reverts vi.spyOn wrappers to the original function entirely. Enabling both is the safest default; it means a spy set up in one test never bleeds its calls or its behaviour into the next.
Verification & Assertions
A module mock is only trustworthy if you can prove three things: the replacement was actually applied, the right export was called with the right arguments, and nothing you meant to preserve was accidentally clobbered. Assert application by checking that a mocked export carries the vi.fn() marker — expect(vi.isMockFunction(mailer.send)).toBe(true) — before you rely on it. Assert interaction with toHaveBeenCalledWith and toHaveBeenCalledTimes, which together catch both missing and surplus calls.
import { vi, expect, it, beforeEach } from 'vitest';
import { send } from './mailer';
import { sendWelcome } from './notifier';
beforeEach(() => vi.clearAllMocks());
it('calls the mailer exactly once with the recipient', async () => {
await sendWelcome('grace@example.com');
expect(send).toHaveBeenCalledTimes(1);
expect(send).toHaveBeenCalledWith(
expect.objectContaining({ to: 'grace@example.com' }),
);
});
For partial mocks, add an assertion that the preserved export still behaves like the real one, so a future refactor that accidentally stubs it out fails loudly. The verification discipline mirrors the one used in HTTP request stubbing techniques: assert both presence and absence, and let the reset hooks guarantee a clean slate each test.
The strongest module-mock tests also assert negative space — that the double was not called when the code path should have skipped it. A expect(send).not.toHaveBeenCalled() on the branch where dispatch is suppressed is as valuable as the positive assertion, because it proves the code’s control flow, not just that a function exists. Together, positive and negative interaction assertions turn a mock from a passive stand-in into an active contract that the unit under test is checked against on every run.
Edge Cases & Failure Modes
Reference before initialization. Symptom: ReferenceError: Cannot access 'x' before initialization on a variable used inside a vi.mock factory. Diagnosis: the factory runs hoisted, above the const declaration. Fix: wrap the value in vi.hoisted, as detailed in the hoisting pitfalls guide.
Partial mock accidentally becomes total. Symptom: an export you never touched is suddenly undefined. Diagnosis: your factory returned a fresh object without spreading importOriginal(), so every unlisted export vanished. Fix: spread the actual namespace first, then override.
Default export is undefined under CommonJS. Symptom: import thing from 'pkg' yields undefined even though the mock defines a default. Diagnosis: CommonJS interop wrapped the module, so default sits behind an extra layer. Fix: align your mock’s shape with the interop, covered in mocking ES modules vs CommonJS in Vitest.
A third-party package ignores your mock. Symptom: vi.mock('some-pkg') has no effect. Diagnosis: the package ships pre-bundled and is not transformed by Vitest, so the mock registry never intercepts it. Fix: add it to server.deps.inline so Vitest processes it, then the mock applies.
A mock leaks into an unrelated test file. Symptom: a test passes in isolation but fails when the full suite runs, and the failure references a dependency the test never mocks itself. Diagnosis: a top-level vi.mock in a sibling file registered the mock for a module both files import, and because the two files shared a worker the registry entry survived across them. Fix: keep isolate: true so each file gets a fresh module registry, and prefer vi.doMock inside a single test when a mock must be scoped tighter than a whole file. A quick way to confirm the diagnosis is to run the failing file alone with --no-isolate toggled off and watch the failure disappear.
A spy reports zero calls even though the code ran. Symptom: expect(spy).toHaveBeenCalled() fails, yet logging proves the function executed. Diagnosis: you spied on a copy of the export — the module under test captured the original binding at import time, so your later vi.spyOn replaced a different reference. Fix: spy on the exact namespace object the consumer reads from, or replace the export through vi.mock with a factory that returns the spy, so there is only one binding for both the test and the code under test to share.
Performance & CI Impact
Module mocks are essentially free at runtime — they resolve during graph construction and add no per-call overhead — so their performance story is about isolation, not speed. Keep isolate: true and pool: 'forks' so a mock registered in one file cannot leak into a sibling file sharing a worker; a leaked module mock is one of the hardest CI flakes to trace because it only manifests under a particular file execution order. Enabling clearMocks and restoreMocks globally removes an entire class of order-dependent failures at zero cost.
The one place module mocking can slow a suite is server.deps.inline: forcing a large third-party package through Vitest’s transform on every worker startup adds real time. Inline only the specific packages whose mocks you need, never a broad glob. Pair that with sharding across CI runners and a clean reset discipline, and a heavily mocked suite stays both fast and deterministic as it scales toward thousands of tests, exactly as tuned in Vitest configuration & setup.
There is a subtler CI cost worth naming: maintenance drift. Every module mock encodes an assumption about the interface it replaces, and those assumptions silently rot as the real modules evolve. Partial mocks bound that cost by delegating everything they do not override to the real implementation, but even a partial mock’s single overridden export can drift. The mitigation is to keep mocks typed — pass the module type through importOriginal<typeof import('./mod')>() and wrap spies in vi.mocked — so the compiler flags a signature change the moment it happens rather than letting a stale mock pass a test that no longer reflects production. Treat a mock that has not failed in a year with suspicion, not comfort: it may be testing an interface that no longer exists. A cheap periodic audit is to delete a suspect mock, let the test fall back to the real module, and see whether anything breaks; if the suite stays green, the mock was insulating you from nothing and can be retired for good. Fewer, better-typed mocks almost always beat a large registry of stale ones that quietly encode last year’s contracts.
In This Topic Area
- Avoiding vi.mock hoisting pitfalls — why
vi.mocklifts above your imports and howvi.hoistedfixes reference-before-init errors. - Partial mocking with vi.importActual — keep the real module and override just the one export you need to control.
- Mocking ES modules vs CommonJS in Vitest — navigate default-export interop and live bindings across both module formats.
- Spying on default exports in Vitest — the reliable ways to spy on a module’s default export without breaking its callers.
Related
- Advanced Mocking & Service Isolation Patterns — the parent discipline this topic area sits within.
- HTTP request stubbing techniques — replace the transport boundary instead of the whole module when you only need to control a response.
- Stubbing axios interceptors in Vitest — mock a client’s interceptor pipeline while keeping its real serialization.
- Vitest configuration & setup — tune
pool,isolate, and reset flags so mocks never leak. - Back to Advanced Mocking & Service Isolation Patterns.
Avoiding vi.mock Hoisting Pitfalls
Why vi.mock hoists above your imports, why it throws 'cannot access before initialization', and how vi.hoisted and factory functions resolve every ordering bug.
Partial Mocking with vi.importActual
Keep a module's real implementation and override only one export using vi.importActual in Vitest. Factory patterns, typing, verification, and common failure modes.
Mocking ES Modules vs CommonJS in Vitest
Why module mocks apply to ESM but silently fail for CommonJS packages in Vitest, how default-export interop works, and how deps.inline and interopDefault fix it.
Spying on Default Exports in Vitest
Reliable ways to spy on a module's default export in Vitest: object defaults, function defaults, live-binding limits, vi.spyOn vs vi.mock, and interop gotchas.