Spying on Default Exports in Vitest

Spying on a named export is easy; spying on a default export is where teams stall. vi.spyOn(module, 'default') sometimes works, sometimes throws Cannot redefine property, and sometimes silently spies on a copy that the real code never calls. This guide is for engineers on Vitest 1.x or 2.x who need to observe or replace a module’s default export — a default-exported logger, a singleton client, a React component, or a factory function — without breaking the callers that import it. The reliable approach depends on whether the default is an object or a function and on the ESM live-binding rules that make imported names read-only. It is a focused technique within module and dependency mocking, and mastering it closes the last common gap in module isolation.

Root Cause Analysis

The difficulty has two sources. The first is that an ES module’s imported binding is read-only and live: when you write import logger from './logger', logger is a reference into the module, and you cannot reassign it from the outside. So the naive instinct — logger.default = vi.fn() or reassigning the import — is impossible for a function default, because there is no writable property to swap. vi.spyOn needs a host object with a configurable property to wrap, and a bare function default does not present one to the importer.

The second source is what the default actually is. If the default export is an object (export default { info, error }), then the object is a mutable host and vi.spyOn(loggerModule.default, 'info') works cleanly — you are spying on a method of an object, not on the binding itself. But if the default is a function (export default function send() {}), there is no method to spy on; you must replace the whole default binding through vi.mock, because that is the only mechanism that can substitute a module binding rather than a property. Confusing these two cases is why the same vi.spyOn line works in one file and throws in another.

Interop compounds it. As covered in mocking ES modules vs CommonJS in Vitest, a CommonJS default arrives through an interop layer, so the property you try to spy on may sit one level deeper than you expect. The fix is always to match your spy target to how the consumer dereferences the default, and to prefer vi.mock with a factory whenever the default is a function you need to fully control.

Two default-export shapes need two different techniques An object default exposes methods you can spy on with vi.spyOn, while a function default has no host property and must be replaced through a vi.mock factory. What is the default export? Object default export default { info, error } vi.spyOn(mod.default, 'info') host object has a property to wrap Function default export default function send() vi.mock(path, factory) no property — replace the binding
An object default is spied on with vi.spyOn; a function default must be replaced through vi.mock.

Reproducible Setup

Create two modules, one with an object default and one with a function default, so both cases are in play.

npm install -D vitest
// src/logger.ts  (object default)
export default {
  info: (msg: string) => console.log(`[info] ${msg}`),
  error: (msg: string) => console.error(`[error] ${msg}`),
};
// src/send.ts  (function default)
export default function send(to: string): Promise<{ ok: boolean }> {
  return fetch('/api/send', { method: 'POST', body: to }).then(() => ({ ok: true }));
}
// src/service.ts
import logger from './logger';
import send from './send';

export async function dispatch(to: string) {
  logger.info(`dispatching to ${to}`);
  return send(to);
}

Implementation

Step 1 — Spy on a method of an object default. When the default is an object, import it and spy on the method directly. Because the object is a shared mutable reference, the spy applies to the same instance the service uses. Restore afterward so the wrapper does not leak.

// src/service.logger.test.ts
import { vi, it, expect, afterEach } from 'vitest';
import logger from './logger';
import { dispatch } from './service';

afterEach(() => vi.restoreAllMocks());

it('logs before dispatching', async () => {
  const infoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {});
  await dispatch('ada@example.com');
  expect(infoSpy).toHaveBeenCalledWith('dispatching to ada@example.com');
});

Step 2 — Replace a function default with a vi.mock factory. A function default has no property to spy on, so mock the module and expose a spy under default. The imported send then is the spy.

import { vi, it, expect } from 'vitest';
import send from './send';
import { dispatch } from './service';

vi.mock('./send', () => ({ default: vi.fn().mockResolvedValue({ ok: true }) }));

it('calls the default send exactly once', async () => {
  await dispatch('grace@example.com');
  expect(vi.mocked(send)).toHaveBeenCalledWith('grace@example.com');
});

Step 3 — Preserve the real default while spying, when needed. If you want the genuine function to still run but observe its calls, combine vi.importActual with a factory that wraps the original in vi.fn. This keeps behaviour real and records interactions — a partial approach detailed in partial mocking with vi.importactual.

vi.mock('./send', async (importOriginal) => {
  const actual = await importOriginal<typeof import('./send')>();
  return { default: vi.fn(actual.default) };
});

Step 4 — Type the spy target. Use vi.mocked(send) for function defaults and vi.spyOn for object methods; both preserve signatures so a changed argument shape fails at compile time rather than at runtime. Never cast through any to force a spy — a cast hides exactly the drift the test should catch.

Choosing a spy technique by default-export shape From the imported default, an object routes to vi.spyOn on a method while a function routes to a vi.mock factory that replaces the binding, both ending in an assertion. import default inspect shape object → spyOn wrap a method function → vi.mock replace binding assert calls toHaveBeenCalled
Inspect the default's shape first: object defaults take vi.spyOn, function defaults take a vi.mock factory.

Verification

Confirm the spy is wired to the same reference the code under test calls, not a copy. For an object default, assert the method reports as a mock and that it recorded the call. For a function default, assert through vi.mocked and check the call count so a stray extra invocation is caught.

import { vi, it, expect } from 'vitest';
import logger from './logger';
import send from './send';
import { dispatch } from './service';

vi.mock('./send', () => ({ default: vi.fn().mockResolvedValue({ ok: true }) }));

it('observes both defaults on the real path', async () => {
  const infoSpy = vi.spyOn(logger, 'info').mockImplementation(() => {});
  await dispatch('lin@example.com');
  expect(infoSpy).toHaveBeenCalledTimes(1);
  expect(vi.isMockFunction(send)).toBe(true);
  expect(send).toHaveBeenCalledTimes(1);
});

If the object-default spy reports zero calls while the code clearly logs, you likely spied on a re-imported object that differs from the service’s reference — ensure both import the same module specifier so they share one instance.

The recurring theme across every default-export failure is reference identity: a spy only works when it wraps the exact value the code under test reaches for. For an object default that means one shared object instance; for a function default it means the module binding itself, which is why replacement rather than property-spying is the only reliable route. When a spy mysteriously records nothing, resist adding retries or timing hacks and instead confirm the two sides resolve to the same module record — mismatched path aliases and duplicated package copies in a monorepo are the usual culprits.

Spy works only when references match When the test and the code under test resolve to the same module record the spy records calls, but two different records leave the spy observing nothing. Same record — spy fires test code one module Two records — spy silent test → copy A code → copy B module A module B
A spy records calls only when test and code resolve to one module record; duplicated records leave it silent.

Troubleshooting

Cannot redefine property: default. Diagnosis: you tried vi.spyOn(module, 'default') on a namespace whose default is non-configurable, which is common for transformed ESM. Fix: for a function default, do not spy on the binding — replace it with a vi.mock factory that returns { default: vi.fn() }. Reserve vi.spyOn for methods of an object default.

The spy never fires even though the code runs. Diagnosis: the test and the module under test imported two different instances of the default — often because one used a path alias and the other a relative path. Fix: normalize import specifiers so they resolve to one module, and remember that ESM live bindings mean the service reads the binding by reference, so both sides must point at the same module record.

Default is undefined inside the mock. Diagnosis: the module is CommonJS and the default arrives through interop one level down. Fix: shape the factory to match, exposing the value under default and, if consumers also read it directly, at the top level too — the dual-shape pattern from mocking ES modules vs CommonJS in Vitest.

FAQ

Why can’t I just reassign the imported default to a spy?

Because an ESM import binding is read-only and live. import x from './m' gives you a reference into the module that you are not permitted to reassign from the outside — attempting it is a syntax or type error, and even where a bundler tolerates it, the module’s own internal references would not see your change. That immutability is exactly why Vitest provides vi.mock to replace a binding at the module boundary and vi.spyOn to wrap a property on a mutable host object, rather than expecting you to overwrite the import.

When should I use vi.spyOn versus vi.mock for a default export?

Use vi.spyOn when the default is an object and you want to observe or stub one of its methods while keeping the object otherwise intact — it is the lighter touch and restores cleanly. Use vi.mock with a factory when the default is a function, because there is no property to wrap and the only way to substitute a function binding is to replace the whole module namespace. If you want the real function to still run but recorded, combine vi.mock with vi.importActual and wrap the original in vi.fn.

How do I keep the real default running while still counting its calls?

Mock the module with a factory that imports the original via importOriginal, then return { default: vi.fn(actual.default) }. Passing the real function into vi.fn produces a spy that delegates to the genuine implementation, so behaviour is preserved while call arguments and counts are recorded. Assert through vi.mocked(theDefault) to read that recorded history with full types.

Does restoreAllMocks undo a vi.mock default replacement?

No. vi.restoreAllMocks only reverts spies created by vi.spyOn back to their original implementations; it does not un-register a module replaced by vi.mock, which stays mocked for the whole file. To reset a vi.mock you clear its call history with vi.clearAllMocks or, for dynamic re-imports, call vi.resetModules and vi.unmock. Keeping restoreMocks and clearMocks configured makes the spy side automatic; the module side is governed per file by the hoisted registration.