Mocking ES modules vs dependency injection in Vitest

This page is part of the mock boundaries section of the component testing fundamentals guide, which covers choosing where a fake replaces the real thing.

The specific problem: you need a component’s getUser call to return a fixture. You can hoist a vi.mock for the module it comes from, or you can pass the function in. Both work, and the two choices age very differently.

Problem statement

vi.mock('./userService') replaces a module in the registry for the whole file. It requires no change to the component, which is why it is reached for first — and it puts the substitution in a place the component’s own code gives no hint of.

The symptom appears months later. Someone moves userService.ts to services/user.ts, the import path in the mock no longer matches, and the mock silently stops applying. The test now exercises the real service, which either hits the network or throws, and the failure names neither the mock nor the move.

Root cause

The two approaches substitute at different boundaries. Module mocking operates on the resolution of an import specifier — a string. Injection operates on a value the component receives. A string has no type and no reference from the component’s perspective; a value has both.

That difference is what determines how each fails. A mistyped or stale specifier fails open: the real module loads, quietly. A missing injected value fails closed: TypeScript rejects the render call, immediately.

Two places to substitute a dependency Top lane: the component imports getUser directly, and vi.mock swaps the entry in the module registry, highlighted, so the test's fake is returned in its place without the component knowing. Bottom lane: the component accepts getUser as a prop or context value, and the test passes the fake in explicitly, so no registry manipulation is involved. vi.mock — the swap happens at the module edge, invisibly Component imports getUser module registry swapped by vi.mock test's fake returned instead Injection — the swap is a value the test passes in Component takes getUser prop test passes fake explicit fake called no registry involved

Minimal reproduction

// UserPanel.tsx — imports the service directly
import { getUser } from './userService';

export function UserPanel({ id }: { id: string }) {
  const [user, setUser] = useState<User | null>(null);
  useEffect(() => { getUser(id).then(setUser); }, [id]);
  return <span>{user?.name ?? 'Loading…'}</span>;
}
// the mock is keyed on a string, and nothing checks that the string is right
vi.mock('./userService', () => ({
  getUser: vi.fn(async () => ({ id: '42', name: 'Ada Lovelace' })),
}));

Rename the file and this test keeps compiling, keeps running, and stops mocking.

Step-by-step fix

1. Prefer injection when the component is yours

// UserPanel.tsx — the dependency is now part of the component's contract
type Props = { id: string; getUser?: (id: string) => Promise<User> };

export function UserPanel({ id, getUser = defaultGetUser }: Props) {
  const [user, setUser] = useState<User | null>(null);
  useEffect(() => { getUser(id).then(setUser); }, [id, getUser]);
  return <span>{user?.name ?? 'Loading…'}</span>;
}

What this does: production keeps the default, so nothing at the call site changes, while a test can pass its own implementation — checked against the real signature by the compiler.

2. Pass the fake from the test

it('renders the user name', async () => {
  const getUser = vi.fn(async () => ({ id: '42', name: 'Ada Lovelace' }));
  render(<UserPanel id="42" getUser={getUser} />);

  expect(await screen.findByText('Ada Lovelace')).toBeInTheDocument();
  expect(getUser).toHaveBeenCalledWith('42');
});

What this does: the whole scenario is readable in one place, and changing getUser’s signature makes this line fail to compile rather than fail at runtime.

3. Use vi.mock when the dependency is deep or not yours

Threading a prop through five layers to reach a leaf is worse than mocking the module. So is trying to inject into a third-party component.

// mock factories are hoisted above imports, so reference nothing from module scope
vi.mock('@analytics/browser', () => ({
  track: vi.fn(),
  identify: vi.fn(),
}));

// read the typed handle inside the test, after imports have run
const { track } = await import('@analytics/browser');
expect(vi.mocked(track)).toHaveBeenCalledWith('panel_viewed', { id: '42' });

What this does: replaces a dependency you cannot restructure, at the only boundary available.

4. Guard the specifier so a move cannot fail open

// the path is now type-checked: a moved module fails the build, not the assertion
import type * as UserService from './userService';

vi.mock<typeof UserService>('./userService', () => ({
  getUser: vi.fn(async () => ({ id: '42', name: 'Ada Lovelace' })),
}));

What this does: ties the string to a real import, so renaming the module breaks compilation and the stale mock is impossible to miss.

Verification

Module mocking against injection, six ways Six rows. Module mocking needs no component change while injection requires accepting a prop or context value. Module mocking has hoisting rules to learn; injection has none. A module mock's fake is typed manually through vi.mocked, whereas an injected fake matches the real signature. A module mock breaks when an import path changes; an injected one breaks when the signature changes. Module mocking reaches transitive dependencies, while injection only reaches what you thread through. Injection is readable in one file; module mocking puts the substitution somewhere else. Concern vi.mock Injection Component change needed none accept a prop or context Hoisting rules to know yes — calls are hoisted none Type safety of the fake manual, via vi.mocked the real signature Refactor-safe breaks on a path change breaks on a signature change Works for transitive deps yes only what you thread through Readable in one file no — swap is elsewhere yes
vitest run src/UserPanel.test.tsx --reporter=verbose
# ✓ renders the user name   14 ms
# ✓ calls getUser once with the id   9 ms

The stronger verification is a deliberate rename: move the service file and re-run. With injection or a typed mock the build fails immediately. With an untyped string mock the suite still passes while testing the real module — which is the failure mode the typing exists to remove.

Edge cases and caveats

Choosing where to substitute A decision node asking whether the component can be changed. If it can and the code is yours, inject the dependency, which is clearest and type-safe. If it can but the dependency is several layers deep, module mocking beats threading a prop through every level. If the component is third-party, module mocking is the only edge available. If the dependency is an HTTP call the component makes itself, mock the wire with MSW rather than the module. Can you change the component? Inject clearest and type-safe yes, and it is yours vi.mock threading it is worse yes, but the dep is deep vi.mock the only edge available no — third-party MSW mock the wire, not the module it makes HTTP calls
  • Hoisting. vi.mock calls are moved above every import, so a factory referencing a module-scope constant throws “Cannot access before initialization”. Define fixtures inside the factory, or use vi.hoisted to declare them explicitly.

  • Partial mocks. Replacing a whole module when you only need one export removes the rest. importActual inside the factory keeps the real implementations and overrides just the one you care about.

  • Default exports. A module mocked with a factory needs default set explicitly; omitting it makes the default import undefined, which usually surfaces as a confusing “is not a function”.

Migrating a file from module mocks to injection

Converting an existing test file is worth doing when its mocks have started breaking on unrelated refactors. The order matters, because doing it in one step usually means a red suite for an afternoon.

Start by adding the optional prop with a production default. Nothing else changes: every existing call site keeps working, the existing vi.mock keeps applying, and the suite stays green. This step is safe to land on its own.

Then convert one test to pass the fake explicitly and delete nothing. The file now has both mechanisms active, which is momentarily redundant but proves the injected path works before you remove the safety net.

Then remove the vi.mock and convert the remaining tests. Any test you missed fails immediately and loudly, because the real module is now loaded — which is exactly the signal you want, and the reason for doing this step last.

Finally, check what the real module does when it loads. A service module that opens a connection or reads configuration at import time will now run during the test, and that side effect is precisely what the module mock was hiding. Moving it behind a function call is a good change regardless of testing.

The end state to aim for is not zero module mocks. It is that every remaining module mock exists because the dependency is third-party or genuinely deep, rather than because it was the first thing to hand.

FAQ

Is dependency injection worth changing a component for?

For a dependency the component genuinely varies — a data fetcher, a clock, an analytics client — yes: the optional prop costs one line, defaults to production behaviour, and makes the seam explicit to every future reader. For a dependency that never varies, adding a prop only to satisfy a test is over-fitting the design to the suite, and module mocking is the better trade.

How do I mock a module used by a component several levels down?

Module mocking, without hesitation. Threading a prop through intermediate components that have no interest in it couples them to a detail of their descendant, and every future refactor has to maintain the thread. The registry swap is exactly the right tool when the dependency is not adjacent.

Should I mock the module or the network?

If the component calls fetch or axios itself, mock the network with MSW — it keeps your own code, including error handling and parsing, under test. If the component imports a named function from a service module, mock or inject at that module boundary. Doing both leaves one of the two fakes unexercised, and an unexercised fake drifts without anyone noticing.

Does vi.mock work with dynamic imports?

Yes — the registry entry is replaced regardless of how the module is requested, so a component that lazy-loads with import() receives the mock. The ordering caveat still applies: the mock must be declared at the top level of the test file so hoisting puts it before any import that could resolve the real module first.