Mock function - это заглушка, которая запоминает свои вызовы и позволяет задать возврат или поведение. В Vitest её создаёт vi.fn(). У такой функции есть история: mock.calls хранит аргументы каждого вызова, а матчеры вроде toHaveBeenCalledWith и toHaveBeenCalledTimes проверяют, как её вызывали. Смысл мока не в том, чтобы просто зафиксировать факт вызова, а в том, чтобы проверить результат и взаимодействие на границе системы.
Классический случай - зависимость на границе. Сервис оформления заказа зависит от платёжного шлюза и от рассыльщика писем; в тесте оба заменяют vi.fn() с заданным возвратом. После вызова checkout проверяют не внутренности сервиса, а наблюдаемое: что charge вызван с нужной суммой и валютой, а sendReceipt - ровно один раз. Тест описывает контракт сервиса с его границами, а не его реализацию.
Поведение мока задают методами. mockReturnValue возвращает значение синхронно, mockResolvedValue и mockRejectedValue - промис, mockImplementation подменяет тело целиком. У каждого есть Once-вариант (mockResolvedValueOnce), который действует на один следующий вызов, - это ключ к моделированию последовательности разных ответов, а не одного постоянного.
Последовательность ответов проверяет логику повторов. Функцию request мокируют так, чтобы первый вызов упал с таймаутом, а второй вернул успех, и убеждаются, что обёртка withRetry довела дело до конца и сходила ровно дважды. Здесь мок описывает не одно значение, а сценарий во времени - именно то, ради чего повтор и существует.
Проверять стоит результат и аргументы, а не просто факт вызова. toHaveBeenCalled говорит лишь, что функция сработала; toHaveBeenCalledWith(expect.objectContaining(...)) проверяет, что она получила важные поля. Первый матчер пропускает вызов с неверными аргументами, второй его ловит. Точность проверки взаимодействия так же важна, как точность проверки значения. Тот же принцип и в toHaveBeenCalledTimes: важно не что функцию вызвали, а сколько раз и с какими аргументами.
Spy оправдан, когда сам вызов и есть контракт: отправка доменного события, запись метрики, коммит транзакции. Здесь наблюдаемый эффект - именно вызов границы, и проверять его правильно. Но проверять порядок приватных вызовов внутри модуля не нужно: это привязка к реализации, из-за которой безобидный рефакторинг ломает тест, хотя поведение снаружи не изменилось.
Моки требуют изоляции. Между тестами их историю очищают, а подменённые методы восстанавливают - политику clearMocks/restoreMocks задают один раз, о ней подробно в главе про lifecycle. Без этого история вызовов протекает из теста в тест, и toHaveBeenCalledTimes начинает считать чужие вызовы, давая ложные падения, зависящие от порядка.
Типичный провал - мок, который не отражает реальный контракт границы: возвращает лишнее поле, не тот тип или всегда успех там, где реальный сервис иногда падает. Тест зелёный, а прод - нет, потому что проверяли выдуманное поведение. Мок должен быть честной заглушкой границы: его форма и режимы отказа обязаны совпадать с тем, что делает настоящая зависимость.
test('отправляет чек только после успешной оплаты', async () => {
const gateway = { charge: vi.fn().mockResolvedValue({ paymentId: 'pay-1' }) }
const mailer = { sendReceipt: vi.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 = vi.fn()
.mockRejectedValueOnce(new TimeoutError())
.mockResolvedValueOnce({ ok: true })
await expect(withRetry(request)).resolves.toEqual({ ok: true })
expect(request).toHaveBeenCalledTimes(2)