A module mock is a last resort. It replaces the whole module and is appropriate at a real boundary: a payment SDK, the file system, the network, a runtime API that cannot be substituted otherwise. For domain logic it is almost always redundant: there dependency injection works more cleanly, passing the dependency explicitly rather than intercepting it at the import level. The less module magic, the clearer what exactly is replaced. Injection also makes the dependency visible right in the signature, whereas a module mock hides it in an implicit import that is easy to forget.
When a mock is needed after all, most often it is partial: keep the real module and replace one function. vi.mock with a factory and importOriginal takes the real exports and overrides only what is needed - for example trackPurchase, leaving the rest as is. This way the test does not lose the real behavior of neighboring functions and does not turn the mock into a parallel reimplementation of the whole module.
The crucial subtlety is hoisting. The vi.mock call is hoisted with its factory above the static imports, so inside the factory you cannot reference an ordinary variable from the file: at the moment it runs the variable does not exist yet. For this there is vi.hoisted: it prepares values available to the hoisted factory. The typical pairing is to declare a mock function via vi.hoisted and return it from the vi.mock factory.
If a mock is needed not for the whole file but after a condition or in one test, you use vi.doMock. It is not hoisted and applies to the next dynamic import, so the module is imported via await import after the mock is set. This gives pointwise control: different tests in one file can see different implementations of the boundary without interfering with each other.
Global automock rules that nobody on the team can explain are worth avoiding. An explicit vi.mock of a specific module reads and can be grepped; an invisible auto-replacement of all modules by a glob turns a failure into a riddle. The rule is the same as with spies: replace pointwise and exactly what you describe, not everything around just in case.
In Browser Mode the rules differ: the ESM namespace there is sealed, and you cannot override exports arbitrarily. vi.mock works with the { spy: true } option, and some familiar replacement tricks are unavailable. This is not a ban on browser tests but a reminder: module mocks are heavily tied to the environment, and what works in Node may need a different approach in the browser.
A module mock lives until the end of the file, so isolation is mandatory. Mock history is cleared between tests and the replaced is restored - by the same clearMocks/restoreMocks policy from the lifecycle chapter. Without it the replacement and the accumulated calls leak into neighboring tests, and the result again starts to depend on run order.
The typical failure is mocking a module where injection would have sufficed: the test ties itself to the import path and falls apart when the file moves. The second is a partial mock that forgot the real exports: you replaced one thing, and neighboring code that quietly relied on the real function broke inside the test for no obvious reason. Mock the boundary, not the convenient module at hand.
vi.mock(import('./analytics'), async (importOriginal) => {
const actual = await importOriginal()
return {
...actual, // keep the real exports
trackPurchase: vi.fn(), // replace only one function
}
})const { charge } = vi.hoisted(() => ({ charge: vi.fn() }))
vi.mock(import('./gateway.js'), () => ({ charge }))
charge.mockResolvedValue({ id: 'pay-1' })
// vi.mock is hoisted above imports, so charge is prepared via vi.hoisted.vi.doMock(import('./gateway.js'), () => ({
charge: vi.fn().mockResolvedValue({ id: 'pay-1' }),
}))
const { checkout } = await import('./checkout.js') // this import sees the mock