ConstructiCat Logo
CodeBust.
Browse section ▾

Чрезмерное использование моков.

Тест настраивает столько мок-объектов и заглушенных взаимодействий, что настройка моков затмевает саму проверку, поэтому тест в итоге испытывает моки, а не реальное поведение.

##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

Относитесь к тяжёлому моканию как к обратной связи, а затем снижайте нужду мокать:

  1. Сначала исправьте дизайн. Если для сборки SUT приходится мокать 5+ сотрудников, разделите ответственности или сократите зависимости конструктора. Меньше реальных зависимостей — меньше моков.
  2. Мокайте только на архитектурных границах. Мокайте то, чем вы не владеете (сеть, БД, часы, файловая система, сторонние API); используйте реальные экземпляры собственных доменных объектов и объектов-значений.
  3. Извлеките чистое ядро (functional core / imperative shell). Вынесите логику вычислений/решений из класса, насыщенного вводом-выводом, чтобы её можно было тестировать с нулём моков, оставив тонкую оболочку, которой нужны лишь один-два дублёра на границе.
  4. Предпочитайте проверку состояния проверке взаимодействий. Проверяйте возвращённое значение или итоговое состояние вместо verify(...)/toHaveBeenCalledWith(...) у каждого сотрудника. Оставьте проверки взаимодействий для того единственного побочного эффекта, который по-настоящему важен.
  5. Используйте простейшего дублёра, который работает. Замените сложные моки заглушками (просто возвращают значения) или единым переиспользуемым in-memory fake вместо переопределения каждого метода в каждом тесте.
  6. Выявляйте избыточное мокание динамически. Включение строгих заглушек 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);