Шпион - это обёртка вокруг настоящего метода, который уже существует. jest.spyOn(obj, 'method') находит obj.method, оборачивает его в mock-функцию и возвращает её, но за обёрткой по-прежнему стоит живая реализация. Шпион делает две вещи сразу: записывает каждый вызов - аргументы и число обращений - и по умолчанию всё так же зовёт оригинал и отдаёт его результат. То есть без единой строки он ничего не подменяет, а лишь встаёт между кодом и методом, чтобы наблюдать.
Этим он и отличается от jest.fn. jest.fn() - самостоятельная заглушка: за ней нет реальной реализации, она делает ровно то, что вы задали. Шпион же всегда привязан к тому, что уже есть - к методу конкретного объекта, - и наблюдает или переопределяет именно его. Нужен новый подставной вызов, которого в коде ещё нет, скажем зависимость для сервиса, - берите jest.fn. Нужно проследить или временно изменить метод существующего объекта, не переписывая его, - это работа для spyOn.
Самый недооценённый режим - шпионить, ничего не подменяя. Возьмём CheckoutService с методом applyDiscount, который сервис зовёт внутри checkout. Мы ставим шпиона на service.applyDiscount и запускаем checkout как обычно. Настоящая скидка при этом считается по-настоящему - шпион её не выключил, - а мы дополнительно убеждаемся, что метод получил нужные позиции и что итог сошёлся.
Как только вы дописываете .mockImplementation или .mockReturnValue, оригинал отключается - шпион перестаёт звать живой метод и отвечает по вашему сценарию. Это нужно, когда настоящая реализация мешает: ходит в сеть, читает часы, лезет в файловую систему. Классический пример - Date.now. Сервис штампует чек текущим временем, и без подмены тест зависел бы от секунды запуска. jest.spyOn(Date, 'now').mockReturnValue(...) замораживает время, и поле issuedAt становится предсказуемым.
Здесь всплывает главное преимущество шпиона - его можно вернуть на место. mockRestore() восстанавливает настоящую реализацию: обёртка снимается, Date.now снова показывает реальное время. Это работает только со шпионами, потому что только у шпиона есть что восстанавливать - он помнит оригинал, который обернул. У jest.fn восстанавливать нечего: за ним пустота. Поэтому mockRestore и spyOn - пара.
Делать это вручную в каждом тесте легко забыть, поэтому в конфиге есть настройка restoreMocks: true - она сама вызывает эквивалент restoreAllMocks перед каждым тестом. Шпионить можно не только за методами. Третий аргумент задаёт тип доступа: jest.spyOn(config, 'apiUrl', 'get') перехватывает геттер, а 'set' - сеттер. Так подменяют вычисляемые свойства - флаг окружения или базовый URL, - не трогая остальной объект.
Опасность у шпиона одна, зато коварная - незакрытый шпион. Поставьте jest.spyOn(Date, 'now').mockReturnValue(...) и забудьте restore - обёртка переживёт тест и утечёт в следующие. Соседний тест, который не знает про заморозку времени, вдруг получает застывший Date.now и падает или, хуже, проходит по неверной причине. Такие тесты мигают: по отдельности зелёные, в общем прогоне красные, а виновник сидит в другом файле.
Отсюда простое правило. Нужна новая зависимость, которой в коде нет, - jest.fn. Нужно на время подменить или проследить метод существующего объекта, а потом честно вернуть его, - jest.spyOn с обязательным restore, лучше через restoreMocks. Нужно выключить целый модуль на границе - jest.mock, но это крайняя мера. Шпион - самый деликатный из трёх.
test('считает скидку и наблюдает за вызовом', () => {
const service = new CheckoutService(gateway, mailer)
const applyDiscount = jest.spyOn(service, 'applyDiscount')
const total = service.checkout(order)
// настоящий applyDiscount отработал - мы лишь понаблюдали за ним
expect(applyDiscount).toHaveBeenCalledWith(order.items)
expect(total).toBe(1_490)
applyDiscount.mockRestore()
})const now = jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000)
const receipt = service.checkout(order)
expect(receipt.issuedAt).toBe(1_700_000_000_000)
now.mockRestore() // настоящий Date.now вернулся на место