Мок модуля - крайняя мера. Он подменяет весь модуль целиком и уместен на настоящей границе: SDK платёжной системы, файловая система, сеть, runtime API, который иначе не подставить. Для доменной логики он почти всегда лишний: там чище работает dependency injection, когда зависимость передают явно, а не перехватывают на уровне импорта. Чем меньше module magic, тем понятнее, что именно подменено. Инъекция ещё и делает зависимость видимой прямо в сигнатуре, тогда как мок модуля прячет её в неявном импорте, о котором легко забыть.
Когда мок всё же нужен, чаще всего он частичный: сохранить реальный модуль и подменить одну функцию. vi.mock с фабрикой и importOriginal берёт настоящие экспорты и переопределяет только нужное - например, trackPurchase, оставляя остальное как есть. Так тест не теряет реальное поведение соседних функций и не превращает мок в параллельную реализацию всего модуля.
Важнейшая тонкость - hoisting. Вызов vi.mock поднимается фабрикой выше статических import, поэтому внутри фабрики нельзя сослаться на обычную переменную из файла: на момент её выполнения переменная ещё не существует. Для этого есть vi.hoisted: он готовит значения, доступные поднятой фабрике. Типичная связка - объявить мок-функцию через vi.hoisted и отдать её из фабрики vi.mock.
Если мок нужен не на весь файл, а после условия или в одном тесте, используют vi.doMock. Он не поднимается и действует на следующий динамический import, поэтому модуль импортируют через await import уже после установки мока. Это даёт точечный контроль: разные тесты в одном файле могут видеть разные реализации границы, не мешая друг другу.
Глобальных automock-правил, которые никто в команде не может объяснить, стоит избегать. Явный vi.mock на конкретный модуль читается и находится грепом; невидимая автоподмена всех модулей по маске превращает падение в загадку. Правило то же, что и со спаями: подменяйте адресно и ровно то, что описываете, а не всё вокруг на всякий случай.
В Browser Mode правила иные: пространство имён ESM там запечатано, и переопределять экспорты произвольно нельзя. vi.mock работает с опцией { spy: true }, а часть привычных приёмов подмены недоступна. Это не запрет на тесты в браузере, а напоминание: моки модулей сильно завязаны на среду, и то, что работает в Node, может требовать иного подхода в браузере.
Мок модуля живёт до конца файла, поэтому изоляция обязательна. Историю моков очищают между тестами, а подменённое восстанавливают - той же политикой clearMocks/restoreMocks из главы про lifecycle. Без этого подмена и накопленные вызовы протекают в соседние тесты, и результат снова начинает зависеть от порядка запуска.
Типичный провал - мокнуть модуль там, где хватило бы инъекции: тест привязывается к пути импорта и рассыпается при переезде файла. Второй - частичный мок, забывший про реальные экспорты: подменили одно, а соседний код, который тихо полагался на настоящую функцию, сломался внутри теста без явной причины. Мокируйте настоящую границу, а не удобный модуль, который просто оказался под рукой в импортах.
vi.mock(import('./analytics'), async (importOriginal) => {
const actual = await importOriginal()
return {
...actual, // реальные экспорты сохраняем
trackPurchase: vi.fn(), // подменяем только одну функцию
}
})const { charge } = vi.hoisted(() => ({ charge: vi.fn() }))
vi.mock(import('./gateway.js'), () => ({ charge }))
charge.mockResolvedValue({ id: 'pay-1' })
// vi.mock поднят выше import, поэтому charge готовят через vi.hoisted.vi.doMock(import('./gateway.js'), () => ({
charge: vi.fn().mockResolvedValue({ id: 'pay-1' }),
}))
const { checkout } = await import('./checkout.js') // мок виден этому import