Сервис оформления заказа редко живёт в одиночестве. Модуль checkout импортирует другие модули - аналитику, которая шлёт событие о покупке, и платёжный шлюз, который списывает деньги. Модуль здесь - это отдельный файл с экспортами, а мок модуля - это подмена всех его экспортов подделками на время работы тестового файла. Вызов jest.mock('./analytics') говорит Jest: когда кто угодно импортирует этот путь, отдай не настоящий код, а заглушки. Соблазн понятен - в тесте не нужен реальный сетевой вызов, и одна строка выключает целый файл.
Наивный ход - замокать всё, что мешает, и радоваться изоляции. Это естественно, потому что jest.mock поднимается babel-jest выше всех import в файле, так что подмена срабатывает ещё до того, как тестируемый модуль подтянет свою зависимость. Кажется, что вы получили чистую изоляцию бесплатно.
Ломается это тихо. Мок модуля - действие на расстоянии: он меняет поведение файла, который вы прямо в тесте даже не упоминаете, и найти причину странного результата становится трудно. Мок хрупок - он привязывает тест к форме графа импортов, а не к поведению, поэтому любой рефакторинг путей ломает тесты, ничего не меняя по существу. И мок дрейфует: настоящий модуль обрастает новыми функциями, а заглушка о них не знает и молча возвращает undefined.
Для доменной логики есть способ лучше - dependency injection, внедрение зависимостей. Вместо того чтобы модуль сам тянул шлюз через import, вы передаёте шлюз аргументом или через конструктор: функция checkout(order, { gateway, analytics }) получает свои зависимости снаружи. В тесте вы просто подставляете jest.fn вместо шлюза - без магии, без хойстинга, с полной поддержкой типов. Код, спроектированный под внедрение, честно показывает свои границы прямо в сигнатуре.
Мок модуля оправдан там, куда внедрение не дотягивается: SDK внешнего сервиса, рантайм-API платформы, тяжёлая граница вроде файловой системы или сторонней библиотеки, которую вы не контролируете. Здесь важно не выключать модуль целиком. jest.requireActual подгружает настоящий модуль, а вы разворачиваете его экспорты и подменяете только одну функцию - trackPurchase становится заглушкой, остальное остаётся живым.
С ES-модулями правила другие. Хойстинг jest.mock работает через трансформацию Babel и на нативных ESM не срабатывает, поэтому для них есть jest.unstable_mockModule. Он не поднимается наверх, а значит порядок критичен: сначала объявите мок, и только потом подтяните тестируемый модуль через динамический await import. Если импортировать checkout раньше мока, он свяжется с настоящим шлюзом, и подмена опоздает.
Отдельная ловушка - глобальный automock: включённый в конфиге automock или общий мок в mocks, который автоматически подделывает пакет во всех тестах. Такое правило действует незаметно, и через полгода никто в команде не помнит, почему модуль ведёт себя в тестах не так, как в проде. Цена мока модуля - именно эта непрозрачность: чем шире подмена, тем труднее доверять зелёному прогону.
Классический провал выглядит безобидно. Заглушка платёжного шлюза возвращает захардкоженный { id: 'pay-1' }, тест зелёный, но в проде шлюз сменил формат ответа, и код падает на поле, которого заглушка не знала. Держите подмену узкой, сбрасывайте её между тестами через resetMocks в конфиге или clearMocks, и относитесь к моку модуля как к крайней мере, а не к первому инструменту.
jest.mock('./analytics', () => {
const actual = jest.requireActual('./analytics')
return {
...actual,
trackPurchase: jest.fn(),
}
})jest.unstable_mockModule('./gateway.js', () => ({
charge: jest.fn().mockResolvedValue({ id: 'pay-1' }),
}))
const { checkout } = await import('./checkout.js')