Partial Mocking in Vitest: Replace Only What You Need with vi.importActual
Use Vitest's vi.mock with vi.importActual to partially mock a module — keep real helpers, replace only the heavy or external functions. Includes a worked TypeScript example, hoisting explanation, and common pitfalls.
05 Sept 2025, 19:54 UTC

The Problem: Mocking Too Much Breaks Test Realism
When a module under test imports a large utility file — say, utils.ts — that contains both pure helpers and a heavy function that hits a database, you face a dilemma. Mocking the entire module with vi.mock('./utils') forces you to re-implement every exported function, even the trivial ones. That inflates test boilerplate and drifts from real behavior. The alternative — not mocking at all — makes tests slow, flaky, or impossible to run in CI.
Vitest solves this with a partial mocking pattern: import the actual module inside the factory, spread its exports, and override only the problematic functions. You keep real behavior for everything else.
Worked Example: Mocking a Single Heavy Export
Consider a module that exports a fast formatter and a slow report generator:
// src/report.ts
export function formatDate(date: Date): string {
return date.toISOString().split('T')[0];
}
export async function generateReport(userId: string): Promise<string> {
// Imagine a real DB call here
const data = await fetchUserData(userId);
return `Report for ${data.name}: ${data.metrics.join(', ')}`;
}
async function fetchUserData(id: string) {
// Simulate network latency
await new Promise(r => setTimeout(r, 200));
return { name: 'Alice', metrics: ['login', 'purchase'] };
}
We want to test a consumer that uses both exports, but we don’t want the 200 ms delay in every test. Here’s the test file using partial mocking:
// src/report.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { formatDate, generateReport } from './report';
// Partial mock: keep formatDate real, replace generateReport
vi.mock('./report', async () => {
const actual = await vi.importActual('./report');
return {
...actual,
generateReport: vi.fn().mockResolvedValue('MOCKED REPORT'),
};
});
describe('report consumer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('uses real formatDate but mocked generateReport', async () => {
const dateStr = formatDate(new Date('2024-01-15'));
expect(dateStr).toBe('2024-01-15'); // real implementation
const report = await generateReport('user-123');
expect(report).toBe('MOCKED REPORT'); // mocked implementation
expect(generateReport).toHaveBeenCalledWith('user-123');
});
});
Run with npx vitest run. The test passes, formatDate executes the real code, and generateReport returns the mocked value instantly.
Why This Works: Hoisting and Factory Evaluation
- Hoisting:
vi.mockcalls are hoisted to the top of the file before any imports run. The factory function you pass is executed once per test file, before the first test. - Factory argument: The factory receives the original module as its only argument if you use the callback form. However, the recommended pattern (shown above) uses an async factory that calls
vi.importActual('./report')to get the real exports on demand. - Spread merge:
{ ...actual, generateReport: vi.fn() }creates a new module object where every export from the real module is present, except the ones you explicitly override.
Because the factory runs before the import { formatDate, generateReport } from './report' statement, the imported bindings point to your merged object — real formatDate, mocked generateReport.
TypeScript: Narrowing the Mocked Module Type
Without type hints, TypeScript sees the mocked module as typeof import('./report') and doesn’t know that generateReport is now a Mock instance. Use vi.mocked or the Mocked utility:
import { vi, type Mocked } from 'vitest';
import * as reportModule from './report';
const mockedReport = vi.mocked(reportModule, { deep: true });
// or
const mockedReport: Mocked<typeof import('./report')> = vi.mocked(reportModule);
// Now mockedReport.generateReport is typed as Mock<...>
mockedReport.generateReport.mockResolvedValue('TYPED MOCK');
Add "types": ["vitest/globals"] to tsconfig.json if you prefer global vi without imports.
Common Mistakes and Limits
1. Referencing Variables Declared After vi.mock
Because the factory runs at hoist time, any variable declared after the vi.mock call in the same scope is undefined inside the factory.
// ❌ Broken: mockFetch is undefined when factory runs
vi.mock('./api', () => ({ fetch: mockFetch }));
const mockFetch = vi.fn();
// ✅ Fixed: use vi.hoisted for cross-file reuse
const { mockFetch } = vi.hoisted(() => ({ mockFetch: vi.fn() }));
vi.mock('./api', () => ({ fetch: mockFetch }));
2. Modules with Import-Time Side Effects
If the original module connects to a database or starts a timer on import, vi.importActual will trigger those side effects. The factory must return a safe stub without calling vi.importActual for that module, or restructure the module to defer side effects.
3. ESM vs CommonJS Interop
When mocking an ESM module from a CJS test file (or vice‑versa), the factory return value may need __esModule: true:
vi.mock('./esm-module', () => ({
__esModule: true,
...(await vi.importActual('./esm-module')),
namedExport: vi.fn(),
}));
Vitest 1.0+ handles most cases automatically, but dual-package repos should verify the shape with console.log(await import('./esm-module')) inside a test.
4. vi.unmock Only Affects Future Imports
Calling vi.unmock('./report') in a beforeEach does not restore the real module for tests that already imported it. Restructure suites so each file either mocks or doesn’t mock a given dependency.
5. Snapshot Testing Captures Call Signatures, Not Return Values
expect(mockFn).toMatchInlineSnapshot() serializes the mock’s call history, not its return value. Assert on mockFn.mock.results or the resolved value directly when you need snapshot coverage of output.
Verification Checklist
- Create a minimal project:
npm init vitest@latest. - Add the
report.tsandreport.test.tsfiles above. - Run
npx vitest run— confirm the test passes in < 100 ms (no 200 ms delay). - Add a type-only import
import type { Mocked } from 'vitest'and runtsc --noEmit— should pass. - Check the Vitest changelog for any recent changes to
vi.mockhoisting orvi.hoisted(the API stabilized in 1.0, but edge-case fixes continue).
When to Choose Partial Mocking Over Alternatives
| Approach | Best For | Drawback |
|---|---|---|
Full vi.mock with factory | Replacing an entire external dependency (e.g., HTTP client) | Must re-implement every export used by the test |
Partial mock with vi.importActual | Large utility modules where only 1–2 functions are problematic | Requires the real module to be safely importable |
vi.spyOn on re-exports | Wrapping a single function while keeping the rest of the module real | Only works on object methods or module namespace objects; not on named imports directly |
vi.stubGlobal | Mocking globals like fetch, crypto, console | Does not affect module imports; global only |
Summary
Partial mocking with vi.mock + vi.importActual lets you keep real implementations for the majority of a module while surgically replacing the functions that cause slowness, flakiness, or side effects. The pattern relies on Vitest’s hoisting behavior and the factory’s ability to asynchronously fetch the actual module. Watch for hoisting-order traps, TypeScript typing gaps, and import-time side effects. Verify with a minimal project and tsc --noEmit before adopting in a large suite.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.