Skip to content

Module mocks that did nothing

ts
import { assertMocked, moduleNamespace } from 'vitest-auto-spy';

vi.mock() is the one piece of a ported suite that can fail silently. It is a transform over the module graph, so it has nothing to say when the graph is not what the spec assumed — and what follows is either a test passing for the wrong reason or a failure with no connection to mocking.

assertMocked(namespace, options?)

ts
import * as engine from '@app/pricing-engine';

vi.mock('@app/pricing-engine');

beforeEach(() => {
  assertMocked(engine, { specifier: '@app/pricing-engine', exports: ['createEngine'] });
});

Fails at the line that assumed the mock, naming the module. Without exports it checks that some export is a runner mock; with it, that each named one is — which is what a factory that stubs part of a module and re-exports the rest needs, since a factory that lost the one export the test drives still looks mocked from the outside.

The two ways vi.mock becomes a no-op

A bundler already inlined the module. Under @angular/build:unit-test, or vite-node handed a pre-built entry, a workspace alias (@scope/lib) or a barrel is part of the bundle by the time the mock would be installed. There is nothing left to intercept. No warning is printed.

isolate: false and a module already in the worker graph. A built-in such as node:fs keeps whichever mock reached it first, so the same spec passes or fails depending on the order the worker picked up the files. A run that is green locally and red in CI, at a different file each time, is this.

Neither has a fix inside vi.mock. What works is not mocking the module at all: pass the dependency in — a TestBed provider, a constructor argument, a function parameter — and stub the value. assertMocked is what turns the silent case into a sentence, so that conclusion is reached in one run rather than three.

moduleNamespace(exports, options?)

ts
vi.mock('shaka-player', () => moduleNamespace({ Player: mockConstructor(() => playerStub) }));

Returns { ...exports, default: exports, __esModule: true } — the shape any dependency written to run as both CommonJS and ESM probes for with mod.default ?? mod.

The missing default is the failure this removes. A factory returning bare named exports makes Vitest throw No "default" export is defined on the mock from inside that dependency, with a stack that names the library rather than the factory three lines up in the spec.

lenient

ts
vi.mock('shaka-player', () => moduleNamespace({ Player }, { lenient: true }));

Reads an export the factory did not define as undefined instead of throwing.

The strict default is the better one: it catches a factory that has drifted from the module it stands in for. But Jest did not throw, so a suite ported from it can be reaching for exports it never stubbed — and there the guard fails inside production code, several frames from the assertion that would have said what the test actually wanted. Turn leniency on to port first and tighten later.

then and symbol keys are never claimed, whatever the mode: a namespace that answers to then would be treated as a promise by await import(…) and never resolve.

What this does not do

A helper cannot make vi.mock hoist from inside another function, so there is no mockModule('x', factory) here — Vitest hoists the literal vi.mock call, and a wrapper around it would be hoisted as a call to a function that does not exist yet. When the factory and the tests need to share a fixture, vi.hoisted is the mechanism:

ts
const stripe = vi.hoisted(() => {
  const charge = vi.fn();

  return { charge, createClient: vi.fn(() => ({ charge })) };
});

vi.mock('stripe', () => moduleNamespace(stripe));

Released under the MIT License.