Чрезмерное использование моков.
Тест настраивает столько мок-объектов и заглушенных взаимодействий, что настройка моков затмевает саму проверку, поэтому тест в итоге испытывает моки, а не реальное поведение.
##Signs and Symptoms
Тест — это в основном подготовка: длинные блоки создания моков, строки when(...).thenReturn(...) / mockReturnValue(...) и verify(...), при малом количестве реального проверяемого кода. Выдающие признаки:
- Обвязки моков больше, чем проверок. Настройка — много строк; «действие» — один вызов; «проверка» убеждается, что моки вызвали, а не что результат верен.
- Мокание того, чем владеете вы. Доменные объекты, объекты-значения или сотрудники с чистой логикой мокаются вместо мокания лишь внешних границ (сеть, БД, часы, файловая система, сторонние сервисы).
- Цепочки моков / «крушения поездов». Мок возвращает другой мок, который возвращает ещё один мок, зеркаля граф вызовов продакшена.
- Проверка только взаимодействий. Тест проверяет
toHaveBeenCalledWith(...)у каждого сотрудника и ни разу не проверяет возвращённое значение или итоговое состояние. - Хрупкость при рефакторинге. Перестановка внутренних вызовов или извлечение метода ломает множество тестов, хотя поведение не изменилось.
- Пять и более моков лишь для создания экземпляра SUT — обычно признак того, что у самой SUT слишком много зависимостей.
test('places order', () => {
const inventory = { check: jest.fn().mockReturnValue(true) };
const pricing = { quote: jest.fn().mockReturnValue(42) };
const tax = { calc: jest.fn().mockReturnValue(4.2) };
const wallet = { charge: jest.fn().mockReturnValue({ ok: true }) };
const ledger = { record: jest.fn() };
const emailer = { send: jest.fn() };
const audit = { log: jest.fn() };
const clock = { now: jest.fn().mockReturnValue(0) };
const svc = new OrderService(inventory, pricing, tax, wallet, ledger, emailer, audit, clock);
svc.place(cart);
// проверяет, что моки были вызваны — а не что заказ верен
expect(inventory.check).toHaveBeenCalled();
expect(pricing.quote).toHaveBeenCalled();
expect(wallet.charge).toHaveBeenCalledWith(46.2);
expect(ledger.record).toHaveBeenCalled();
});
##Reasons for the Problem
Почему это происходит
- У проверяемой системы (SUT) слишком много сотрудников-зависимостей. Чрезмерное использование моков обычно — сигнал о дизайне: класс с низкой связностью и множеством зависимостей вынуждает каждый тест поднимать их все. Как формулирует каталожная литература, «если нужно замокать пять внутренних классов лишь для того, чтобы протестировать один метод, у метода слишком много зависимостей — это проблема дизайна, а не тестирования».
- Привычка / рефлекс «мокать всё». Доведение тестирования на основе взаимодействий («лондонская школа») до крайности или рефлекторное мокание ради того, чтобы не трогать БД или сеть, ведёт к моканию чистой логики, которую можно было бы протестировать напрямую.
- Трудно строимые реальные объекты. Когда реальные сотрудники-зависимости неудобно конструировать, мок кажется проще, чем чинить конструктор или добавлять подделку (fake).
Почему это вредно
- Ложная уверенность. Моки кодируют ваши допущения о зависимости. Если реальная реализация расходится с ними, тест всё равно проходит — например, заглушка, утверждающая, что
sum()возвращает только положительные целые, сохраняет зелёный тест после изменения реального метода. Такие тесты могут стать тавтологичными: они лишь проверяют, что написанные вами моки ведут себя как написанные вами моки. Как предупреждает документация самого Mockito, «если замокано всё, тестируем ли мы вообще продакшен-код?» - Хрупкость / высокая стоимость сопровождения. Проверка конкретных вызовов делает детали реализации частью контракта, поэтому безобидные рефакторинги (порядок вызовов, извлечённые помощники) ломают тесты. Это Overspecified Software / Fragile Test у Месароса.
- Плохая читаемость. Сотни строк конфигурации зависимостей погребают то единственное, о чём этот тест, во многом как Mystery Guest — ревьюер не может понять, какое поведение на самом деле проверяется.
- Пропущенные интеграционные баги. Реальная связка между компонентами никогда не задействуется; баги живут именно в швах, которые заменили моки. Высокое покрытие маскирует низкое качество.
##Treatment
Относитесь к тяжёлому моканию как к обратной связи, а затем снижайте нужду мокать:
- Сначала исправьте дизайн. Если для сборки SUT приходится мокать 5+ сотрудников, разделите ответственности или сократите зависимости конструктора. Меньше реальных зависимостей — меньше моков.
- Мокайте только на архитектурных границах. Мокайте то, чем вы не владеете (сеть, БД, часы, файловая система, сторонние API); используйте реальные экземпляры собственных доменных объектов и объектов-значений.
- Извлеките чистое ядро (functional core / imperative shell). Вынесите логику вычислений/решений из класса, насыщенного вводом-выводом, чтобы её можно было тестировать с нулём моков, оставив тонкую оболочку, которой нужны лишь один-два дублёра на границе.
- Предпочитайте проверку состояния проверке взаимодействий. Проверяйте возвращённое значение или итоговое состояние вместо
verify(...)/toHaveBeenCalledWith(...)у каждого сотрудника. Оставьте проверки взаимодействий для того единственного побочного эффекта, который по-настоящему важен. - Используйте простейшего дублёра, который работает. Замените сложные моки заглушками (просто возвращают значения) или единым переиспользуемым in-memory fake вместо переопределения каждого метода в каждом тесте.
- Выявляйте избыточное мокание динамически. Включение строгих заглушек Mockito (по умолчанию
MockitoExtension/MockitoJUnitRunner) выбрасываетUnnecessaryStubbingExceptionдля настроенных, но ни разу не использованных заглушек — дешёвый способ найти моки, которые вам не нужны.
// ДО: 8 моков, проверяем вызовы
const pricing = { quote: jest.fn().mockReturnValue(42) };
const tax = { calc: jest.fn().mockReturnValue(4.2) };
// + ещё 6 моков ...
expect(wallet.charge).toHaveBeenCalledWith(46.2);
// ПОСЛЕ: чистая логика тестируется напрямую — без моков
expect(totalFor(cart, rates)).toBe(46.2);
// подделана лишь реальная граница; проверяем состояние, а не вызовы
const wallet = new InMemoryWallet({ balance: 100 });
const svc = new OrderService(new InMemoryInventory(cart), wallet);
const order = svc.place(cart);
expect(order.total).toBe(46.2);
expect(wallet.balance).toBe(53.8);