Mock-функция - это подставной вызов под наблюдением. jest.fn() создаёт функцию, которая ничего полезного не делает сама, но у неё три лица. Первое - результат: ей можно задать, что вернуть. Второе - история: она запоминает каждый свой вызов. Третье - поведение: она может по сценарию возвращать разное на разных вызовах. Эти три роли и делают её инструментом изоляции - мы подменяем настоящую зависимость управляемой заглушкой и полностью контролируем её ответ.
История вызовов лежит в mock.calls - массиве аргументов каждого обращения, - и поверх неё работают матчеры toHaveBeenCalledWith и toHaveBeenCalledTimes. Возьмём CheckoutService, который принимает платёжный шлюз и почтовик. Оба - моки: gateway.charge и mailer.sendReceipt. После вызова checkout мы спрашиваем историю: шлюз получил сумму 1490 и валюту RUB, а чек ушёл ровно один раз.
Поведение задаётся до вызова. mockReturnValue возвращает синхронное значение, mockResolvedValue - промис с результатом, mockRejectedValue - отклонённый промис. Смысл в детерминизме: настоящий платёжный шлюз ходит в сеть, отвечает по-разному и медленно, а mockResolvedValue({ paymentId: 'pay-1' }) даёт один и тот же предсказуемый ответ мгновенно. Тест перестаёт зависеть от внешнего мира и проверяет только логику сервиса.
Наивная ловушка - принять факт вызова за результат. 'Метод дёрнули - значит, работает.' Но факт вызова не говорит ни о сумме, ни о порядке, ни об эффекте. gateway.charge мог быть вызван с неправильной суммой; sendReceipt мог уйти до того, как оплата подтвердилась. Проверка 'был вызван' зелёная в обоих случаях, а поведение при этом сломано - тест утверждает слишком мало.
Поэтому по умолчанию проверяйте результат и эффект, а не сам факт обращения. Что вернул сервис, в каком состоянии оказалась корзина, ушёл ли чек с правильными данными - это наблюдаемое поведение, ради которого код существует. Проверка состояния переживает рефакторинг: пока результат тот же, тест зелёный, даже если внутренности переписаны. Проверка вызовов, наоборот, ломается от любой перестановки, не меняющей поведение.
Есть случаи, когда сам вызов и есть контракт. Отправка события в шину, инкремент метрики, коммит транзакции, отправка чека клиенту - здесь наблюдаемый результат и есть то, что произошёл вызов с нужными аргументами, другого 'выхода' у него нет. В таких местах toHaveBeenCalledWith - правильное и точное утверждение: мы проверяем именно тот побочный эффект, который сервис обязан произвести.
Поведение можно расписать по шагам. mockRejectedValueOnce(new TimeoutError()) заставит первый вызов упасть, а mockResolvedValueOnce({ ok: true }) - второй пройти; так моделируется нестабильная сеть для логики повторов. Тест на withRetry проверяет две вещи сразу: результат в итоге успешный и запрос был сделан ровно дважды. Здесь количество вызовов - часть контракта повторов, а не привязка к реализации, поэтому проверять его уместно.
Показательный провал - тест, который фиксирует порядок приватных внутренних вызовов: сначала дёрнули validate, потом normalize, потом save. Такой тест привязан к реализации, а не к поведению: стоит переставить шаги или объединить их, не изменив результата, и зелёный тест краснеет без единого настоящего бага. Держите моки на границах системы, проверяйте результат и наблюдаемые эффекты - тогда mock-функции защищают поведение, а не консервируют вчерашний код.
test('отправляет чек только после успешной оплаты', async () => {
const gateway = {
charge: jest.fn().mockResolvedValue({ paymentId: 'pay-1' }),
}
const mailer = {
sendReceipt: jest.fn().mockResolvedValue(undefined),
}
const service = new CheckoutService(gateway, mailer)
await service.checkout(order)
expect(gateway.charge).toHaveBeenCalledWith(1_490, 'RUB')
expect(mailer.sendReceipt).toHaveBeenCalledTimes(1)
})const request = jest.fn()
.mockRejectedValueOnce(new TimeoutError())
.mockResolvedValueOnce({ ok: true })
await expect(withRetry(request)).resolves.toEqual({ ok: true })
expect(request).toHaveBeenCalledTimes(2)