Partial Mocking with vi.importActual
Most of the time you do not want to replace an entire module — you want to keep nine real exports and override the tenth. Stubbing the whole module forces you to re-implement functions that were fine, and every one of those hand-written stubs is a future maintenance liability that drifts from the original. This guide is for engineers on Vitest 1.x or 2.x who need surgical, partial module mocks: preserve the genuine implementation and swap out only the one volatile function — a clock reader, a feature flag, an ID generator, a network call. The tool is vi.importActual, which loads the real module namespace inside your mock factory so you can spread it and override precisely. Partial mocking is a core pattern within module and dependency mocking, and it is what keeps mocks small and honest instead of sprawling and stale.
Root Cause Analysis
The problem partial mocking solves is a fidelity leak that appears the moment you write a full vi.mock factory. When your factory returns a fresh object, every export you did not list becomes undefined. If the module under test imports both the function you wanted to stub and three helpers you did not, those helpers silently vanish, and the failure surfaces far from its cause — a TypeError: x is not a function deep inside code you were not even testing. The root cause is that vi.mock replaces the module namespace wholesale; it does not merge with the original unless you explicitly merge it yourself.
vi.importActual exists to give the factory access to the un-mocked module so you can perform that merge. It bypasses the mock registry and returns the genuine namespace, which you then spread into your replacement. The mental model is a shallow copy with targeted overrides: start from reality, change the single binding you must control, and leave everything else pointing at the real implementation. This keeps the blast radius of a mock as small as the behaviour you actually need to fake, which is the entire discipline behind trustworthy isolation.
There is a deeper reason to prefer partial mocks: they resist drift. A full stub of a module encodes your assumptions about that module’s behaviour, and those assumptions rot as the real code evolves. A partial mock, by contrast, delegates to the real implementation for everything it does not override, so improvements and bug fixes in the module flow through automatically. You only own the maintenance of the one function you deliberately replaced. That principle is the same one that makes stubbing axios interceptors in Vitest prefer adapter-level stubs over re-implementing the whole client.
Reproducible Setup
Create a module with a mix of pure helpers and one non-deterministic function.
npm install -D vitest
// src/clock.ts
export function parse(iso: string): Date {
return new Date(iso);
}
export function format(d: Date): string {
return d.toISOString().slice(0, 10);
}
export function now(): Date {
return new Date();
}
// src/report.ts
import { format, now } from './clock';
export function stamp(): string {
return `report-${format(now())}`;
}
stamp() depends on now(), which is non-deterministic, but it also depends on the perfectly deterministic format(). A full mock would force you to re-implement format; a partial mock keeps it real.
Implementation
Step 1 — Spread the real namespace, override the one export. The factory receives an importOriginal helper (Vitest passes it as the first argument) that returns the genuine module. Spread it, then replace now.
// src/report.test.ts
import { vi, it, expect } from 'vitest';
import { stamp } from './report';
vi.mock('./clock', async (importOriginal) => {
const actual = await importOriginal<typeof import('./clock')>();
return {
...actual,
now: vi.fn(() => new Date('2026-07-24T00:00:00.000Z')),
};
});
it('stamps with a fixed date but real formatting', () => {
expect(stamp()).toBe('report-2026-07-24');
});
Step 2 — Use vi.importActual when you are outside the factory. The importOriginal argument only exists inside the factory. If you need the real module elsewhere — for example to compare mocked output against genuine output — call vi.importActual directly.
import { vi, it, expect } from 'vitest';
it('mocked format matches the real one', async () => {
const real = await vi.importActual<typeof import('./clock')>('./clock');
const d = new Date('2026-01-02T10:00:00Z');
expect(real.format(d)).toBe('2026-01-02');
});
Step 3 — Type the factory so overrides stay honest. Passing the module type to importOriginal<typeof import('./clock')>() gives the spread full inference, so if you misspell an export or change its signature, the compiler flags it. This is what stops a partial mock from silently drifting.
Step 4 — Reach the spy for per-test control. Wrap the imported binding with vi.mocked to adjust return values inside individual tests without rebuilding the factory.
import { vi, it, expect, beforeEach } from 'vitest';
import { now } from './clock';
import { stamp } from './report';
beforeEach(() => vi.clearAllMocks());
it('honours a per-test override', () => {
vi.mocked(now).mockReturnValueOnce(new Date('2027-12-31T00:00:00Z'));
expect(stamp()).toBe('report-2027-12-31');
});
Verification
Verify a partial mock along two axes: the overridden export is a spy, and the preserved exports are still the real implementations. The second check is the one teams forget, and it is exactly what catches an accidental full stub.
import { vi, it, expect } from 'vitest';
import { format, now } from './clock';
it('overrides now but keeps format real', async () => {
const real = await vi.importActual<typeof import('./clock')>('./clock');
expect(vi.isMockFunction(now)).toBe(true);
expect(vi.isMockFunction(format)).toBe(false);
const d = new Date('2026-05-06T00:00:00Z');
expect(format(d)).toBe(real.format(d));
});
If format reports as a mock function, your factory failed to spread the original — the most common partial-mock defect. Asserting that the preserved export matches the genuine namespace turns a silent regression into a hard failure.
Think of the verification as a boundary audit: for each export the module publishes, it should fall into exactly one of two buckets — deliberately overridden, or genuinely preserved — and nothing should be accidentally undefined. A quick way to encode this is to enumerate the real namespace with Object.keys(await vi.importActual(path)) and assert every key is still present on the mocked module. That single loop catches the entire family of “a preserved export vanished” bugs without you having to remember each helper by name.
Troubleshooting
A preserved export is undefined. Diagnosis: the factory returned a new object without spreading importOriginal(), so unlisted exports were dropped. Fix: always const actual = await importOriginal(); return { ...actual, override };. Verify with vi.isMockFunction on an export you did not intend to touch.
importOriginal returns the mock, causing infinite recursion. Diagnosis: you called vi.importActual with a path that itself resolves through another mock, or you used a plain dynamic import() inside the factory instead of importOriginal. Fix: use the importOriginal argument Vitest hands the factory, or vi.importActual, both of which bypass the registry. A plain import() re-enters the mock and loops.
The default export disappears in a partial mock. Diagnosis: spreading the actual namespace copies named exports but you overrode default incorrectly, or the module’s default sits behind CommonJS interop. Fix: preserve default explicitly and consult spying on default exports in Vitest and mocking ES modules vs CommonJS in Vitest for the interop shape.
FAQ
What is the difference between importOriginal and vi.importActual?
They return the same thing — the genuine, un-mocked module namespace — but they are used in different places. importOriginal is the convenience argument Vitest passes into a vi.mock factory, so you use it when building the replacement. vi.importActual is a standalone method you can call anywhere, such as inside a test body when you want to compare mocked output against the real implementation. Under the hood both bypass the mock registry so you never get the mocked version back.
Does spreading the actual module run its side effects again?
Loading the actual module evaluates it once and caches it, so spreading the returned namespace does not re-run top-level side effects on every access — you are copying references to already-initialized exports. If the module has heavy initialization you want to avoid entirely, a partial mock is the wrong tool; replace it fully and stub only the surface the test needs. For ordinary pure-function modules this concern does not arise.
Can I override a nested property instead of a whole export?
Yes, but do it by spreading at the level you need. If an export is an object, spread the actual export and override the single property: { ...actual, config: { ...actual.config, flag: true } }. Keep the spread shallow-but-targeted so you preserve every sibling property. For methods on an object export, vi.spyOn(actual.thing, 'method') is often cleaner than rebuilding the object in the factory.
Should I prefer partial mocks over full mocks by default?
As a rule, yes. Partial mocks keep the real implementation for everything you do not deliberately fake, so they drift less and produce clearer failures. Reach for a full mock only when the real module is genuinely unusable in a test environment — it opens sockets, requires native bindings, or has prohibitive startup cost. In those cases replace the whole thing; otherwise, override the minimum and delegate the rest to reality.
Related
- Back to Module & Dependency Mocking
- Avoiding vi.mock hoisting pitfalls — keep partial-mock factories hoist-safe.
- Spying on default exports in Vitest — preserve a default export while overriding named ones.
- Stubbing axios interceptors in Vitest — the same override-the-minimum principle at the transport layer.
- Vitest configuration & setup — configure reset flags so overrides never leak between tests.