Mocking ES Modules vs CommonJS in Vitest
Your vi.mock works flawlessly on your own source files, then does nothing at all when you point it at a package from node_modules — or the mock applies but import thing from 'pkg' comes back undefined. Both symptoms trace to the same root: ESM and CommonJS expose their exports through different mechanisms, and Vitest has to bridge them. This guide is for engineers on Vitest 1.x or 2.x in mixed-module codebases who need module mocks to behave consistently whether the target is a native ES module, a CommonJS package, or a transpiled hybrid. Understanding the interop layer turns two of the most baffling module-mocking failures into a two-line config change. It builds directly on the mechanics in module and dependency mocking.
Root Cause Analysis
ES modules and CommonJS disagree about what an “export” is. In ESM, exports are live bindings: export const x creates a named slot that consumers read by reference, and a default export is just a binding named default. In CommonJS, there is a single module.exports object, and require returns that object by value. When you write import foo from 'cjs-pkg', a bundler or runtime must decide what foo means — the whole module.exports, or a .default property on it. That decision is interop, and Vitest controls it with deps.interopDefault.
The first failure — a mock that has no effect on a package — is not an interop problem but a transform problem. Vitest can only intercept modules it processes through its own module runner. Your source files always go through that pipeline, so vi.mock('./thing') reliably applies. Many third-party packages, however, are pre-bundled CommonJS that Vitest externalizes to Node’s native require for speed, and a module loaded by Node’s loader never passes through the mock registry. The mock registration succeeds silently and intercepts nothing, which is why the symptom is “no error, no effect.”
The second failure — a default that is undefined or double-wrapped — is pure interop. If your mock factory returns { default: {...} } but the consumer reads the module as CommonJS, the runtime may look for module.exports.default.default, or treat your default as a named export rather than the value import x from expects. The mismatch between how you shaped the mock and how the importer dereferences it is what produces the undefined. Getting mocks right means matching the mock’s shape to the interop the importer uses, exactly the concern that also governs spying on default exports in Vitest.
Reproducible Setup
Set up one native ESM helper and one CommonJS-style dependency so you can watch the two behave differently.
npm install -D vitest
// src/esm-utils.ts (native ESM, transformed by Vitest)
export const slugify = (s: string) => s.toLowerCase().replace(/\s+/g, '-');
export default function banner() { return '=== report ==='; }
// node_modules/legacy-cjs/index.js (CommonJS package, may be externalized)
function greet(name) { return 'hello ' + name; }
module.exports = greet;
module.exports.version = '1.0.0';
// src/page.ts
import banner, { slugify } from './esm-utils';
import greet from 'legacy-cjs';
export function render(title: string) {
return `${banner()} ${slugify(title)} by ${greet('team')}`;
}
Implementation
Step 1 — Mock an ESM module with named and default exports. Provide both keys explicitly. The default key maps straight to the default binding because Vitest transforms your ESM source.
// src/page.test.ts
import { vi, it, expect } from 'vitest';
import { render } from './page';
vi.mock('./esm-utils', () => ({
default: () => '[BANNER]',
slugify: (s: string) => s.toUpperCase(),
}));
vi.mock('legacy-cjs', () => ({ default: () => 'hi team' }));
it('renders with both modules mocked', () => {
expect(render('My Page')).toBe('[BANNER] MY PAGE by hi team');
});
Step 2 — Force a CommonJS package through Vitest’s transform. If the mock in Step 1 does nothing for legacy-cjs, the package is being externalized to Node’s loader. Add it to server.deps.inline so Vitest processes it and the mock registry can intercept.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
server: { deps: { inline: ['legacy-cjs'] } },
},
});
Step 3 — Match the mock shape to the interop. For a CommonJS module whose module.exports is the value itself (a function), the ESM import greet from 'legacy-cjs' reads .default through interop, so your mock must expose the value under default. If the consumer instead uses import * as ns or require, expose the value at the top level. When unsure, mock both shapes: { default: fn, ...fn }.
vi.mock('legacy-cjs', () => {
const greet = vi.fn(() => 'hi');
return { default: greet, ...Object.assign(greet, { version: '9.9.9' }) };
});
Step 4 — Confirm interopDefault matches your imports. Vitest defaults deps.interopDefault to true, which lets import x from 'cjs' resolve to module.exports. Leave it on unless a package already ships a proper ESM default, in which case double-wrapping appears and you disable it for that case.
Verification
Prove both halves independently. First, confirm the mock actually replaced the binding with vi.isMockFunction or an identity check against your fake. Second, confirm the shape is right by reading the export exactly as the consumer does.
import { vi, it, expect } from 'vitest';
import banner from './esm-utils';
import greet from 'legacy-cjs';
vi.mock('./esm-utils', () => ({ default: vi.fn(() => 'X'), slugify: (s: string) => s }));
vi.mock('legacy-cjs', () => ({ default: vi.fn(() => 'Y') }));
it('resolves both default exports through interop', () => {
expect(banner()).toBe('X');
expect(greet('a')).toBe('Y');
expect(vi.isMockFunction(banner)).toBe(true);
});
If greet is undefined here, the CommonJS package was still externalized — return to Step 2 and inline it. If banner is undefined, your factory omitted the default key. Reading the export the way the real consumer reads it is the verification that catches interop mistakes the type checker cannot.
It helps to picture the two independent gates a mock must pass before it is observable in your code. The first gate is transform: is the module processed by Vitest at all, or externalized to Node? A mock that fails this gate has no effect whatsoever. The second gate is shape: once the mock applies, does its namespace expose the export under the key the importer dereferences? A mock that passes the first gate but fails the second applies but reads back undefined. Diagnosing an ESM-versus-CommonJS problem is almost always a matter of identifying which of these two gates the mock is stuck at, because the fix differs completely between them.
Troubleshooting
Mocking a node_modules package does nothing. Diagnosis: the package is externalized to Node’s native loader, bypassing the mock registry. Fix: add it to test.server.deps.inline. A __mocks__ directory adjacent to node_modules is an alternative for packages you always mock, but deps.inline is the surgical per-suite fix.
import x yields undefined despite a default in the factory. Diagnosis: interop expected the value at the top level but you nested it under default, or vice versa. Fix: mirror the real module — if the real import x reads module.exports directly, expose the value at the top level and also under default. The dual-shape return in Step 3 covers both readers.
A mock leaks between an ESM test and a CJS test. Diagnosis: vi.resetModules() was not called, so a cached module instance carried its mocked state across files or dynamic imports. Fix: reset modules between tests that dynamically import, and keep isolate: true so static mocks stay contained per file, as configured in Vitest configuration & setup.
FAQ
Why does vi.mock work on my code but not on an npm package?
Because Vitest only intercepts modules that pass through its own transform pipeline. Your source files always do, so mocks apply reliably. Many published packages are pre-bundled CommonJS that Vitest externalizes to Node’s require for startup speed, and anything loaded by Node’s native loader never reaches the mock registry. Adding the package to server.deps.inline forces it back through Vitest’s pipeline, at which point the mock intercepts it as expected.
What does deps.interopDefault actually change?
It controls how import x from 'cjs-module' is resolved when the module has no real ESM default export. With interopDefault: true — the Vitest default — the whole module.exports object becomes the default import, matching how bundlers and the Node ESM loader treat CommonJS. Turning it off makes the import look for an explicit default property instead, which you only want when a package already ships a genuine ESM default and the interop would otherwise double-wrap it.
Should I mock the ESM build or the CommonJS build of a dual-package?
Mock the build your code actually resolves to under the test runner, which is whatever your import statement pulls in given the package’s exports map and your config. In practice, keep your test environment consistent with production resolution so the mock shape matches what the real importer sees. If production loads the ESM entry, mock with named plus default keys; if it loads CJS, expose the value at the top level and under default.
How do live bindings affect spying on a named export?
In ESM, a named export is a live binding the consumer reads by reference, so reassigning it from outside the module does not work — you cannot overwrite an imported name. That is precisely why vi.mock and vi.spyOn on the module namespace are the supported route: they replace the binding at the module boundary rather than trying to mutate a read-only import. The same constraint shapes how you spy on default exports, covered in the companion guide.
Related
- Back to Module & Dependency Mocking
- Spying on default exports in Vitest — apply the interop shape when the target is a default export.
- Avoiding vi.mock hoisting pitfalls — the hoisting rules apply identically across module formats.
- Partial mocking with vi.importActual — preserve real exports while bridging interop.
- Vitest configuration & setup — where
server.deps.inlineand isolation flags live.