Логотип ConstructiCat
CodeBust.
Навигация по разделу ▾

Чрезмерное мокирование.

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

##Признаки и симптомы

Тест — это в основном подготовка: длинные блоки создания моков, строки 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();
});

Границы запаха. Этот запах — о том, сколько всего мокируется: слишком много зависимостей, дублёры для собственных доменных объектов и объектов-значений, моки на месте логики, которая должна просто выполняться. Жёсткая фиксация точных вызовов, полученных моком, — это Переспецифицированный тест; проверки внутреннего состояния или структуры вместо наблюдаемого поведения — Тестирование деталей реализации. Все три часто встречаются вместе, и способы лечения пересекаются.

##Причины проблемы

Почему это происходит

  • У проверяемой системы (SUT) слишком много зависимостей. Чрезмерное мокирование обычно — сигнал о дизайне: класс с низкой связностью и множеством зависимостей вынуждает каждый тест поднимать их все. Как формулирует каталожная литература, «если приходится мокировать пять внутренних классов лишь для того, чтобы протестировать один метод, у метода слишком много зависимостей — это проблема дизайна, а не тестирования».
  • Привычка / рефлекс «мокировать всё». Доведение тестирования на основе взаимодействий («лондонская школа») до крайности или рефлекторное мокирование ради того, чтобы не трогать БД или сеть, ведёт к мокированию чистой логики, которую можно было бы протестировать напрямую.
  • Трудно строимые реальные объекты. Когда реальные зависимости неудобно конструировать, мок кажется проще, чем чинить конструктор или добавлять подделку (fake).

Почему это вредно

  • Ложная уверенность. Моки кодируют ваши допущения о зависимости. Если реальная реализация расходится с ними, тест всё равно проходит — например, заглушка, утверждающая, что sum() возвращает только положительные целые, сохраняет зелёный тест после изменения реального метода. Такие тесты могут стать тавтологичными: они лишь проверяют, что написанные вами моки ведут себя как написанные вами моки. Как предупреждает документация самого Mockito, «если всё подменено моками, тестируем ли мы вообще продакшен-код?»
  • Хрупкость / высокая стоимость сопровождения. Проверка конкретных вызовов делает детали реализации частью контракта, поэтому безобидные рефакторинги (порядок вызовов, извлечённые помощники) ломают тесты. Это Overspecified Software / Fragile Test у Месароша (см. Переспецифицированный тест и Тестирование деталей реализации).
  • Плохая читаемость. Сотни строк конфигурации зависимостей погребают то единственное, о чём этот тест, во многом как Таинственный гость — ревьюер не может понять, какое поведение на самом деле проверяется.
  • Пропущенные интеграционные баги. Реальная связка между компонентами никогда не задействуется; баги живут именно в швах, которые заменили моки. Высокое покрытие маскирует низкое качество.

##Лечение

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

  1. Сначала исправьте дизайн. Если для сборки SUT приходится мокировать 5+ зависимостей, разделите ответственности или сократите зависимости конструктора. Меньше реальных зависимостей — меньше моков.
  2. Мокируйте только на архитектурных границах. Заменяйте дублёрами лишь то, что пересекает границу, которой вы не владеете (сеть, БД, часы, файловая система, сторонние API), — в идеале через тонкий адаптер с вашим собственным интерфейсом, чтобы тест подменял ваш адаптер, а не API поставщика. Именно в этом смысл принципа Фримена и Прайса «мокируйте только типы, которыми владеете»: оберните сторонний 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);