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.
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
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
-
Hoisting.
vi.mockcalls are moved above every import, so a factory referencing a module-scope constant throws “Cannot access before initialization”. Define fixtures inside the factory, or usevi.hoistedto declare them explicitly. -
Partial mocks. Replacing a whole module when you only need one export removes the rest.
importActualinside the factory keeps the real implementations and overrides just the one you care about. -
Default exports. A module mocked with a factory needs
defaultset explicitly; omitting it makes the default importundefined, 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.
Related
- Mock Boundaries — the parent section on choosing where a fake replaces the real thing
- Setting Up Mock APIs for Frontend Component Tests — intercepting at the network boundary instead of the module boundary
- Resetting MSW Handlers Between Vitest Tests — keeping a per-test override from leaking forward
- State Injection — the same explicit-value principle applied to component state