ConstructiCat Logo
CodeBust.
Browse section ▾

Трудно тестируемый код.

Продакшен-код, чей дизайн (тесная связанность, скрытые зависимости, глобальное состояние, недетерминированный ввод-вывод или сугубо асинхронные интерфейсы) заставляет тесты идти на неуклюжие ухищрения или вовсе делает невозможным проверку модуля в изоляции.

##Signs and Symptoms

Этот запах живёт в продакшен-коде, но обнаруживаете вы его во время написания тестов. Характерная примета — модульный тест не получается написать чисто: тесту приходится бороться с дизайном, чтобы привести тестируемый код в известное состояние и наблюдать результат.

Высматривайте такие симптомы:

  • Гигантские блоки arrange. Чтобы просто инстанцировать тестируемый класс, приходится конструировать паутину коллабораторов («Я не могу протестировать OrderService, не подняв заодно БД, шлюз и загрузчик конфигурации»).
  • Нет шва для внедрения дублёра. Код вызывает new ConcreteThing(), обращается к статическому синглтону (Database.getInstance()) или напрямую лезет в сеть/файловую систему/часы, поэтому подставить заглушку или фейк негде.
  • Недетерминизм, вшитый внутрь. Логика зависит от Date.now(), Math.random(), process.env или системного времени, которые тест не может контролировать, что даёт нестабильные или невоспроизводимые результаты.
  • Нет точки наблюдения / точки управления. Результат, который вы хотите проверить, погребён в приватном состоянии или побочном эффекте, поэтому тесты тянутся к нему через рефлексию, приведения типов или геттеры только для тестов.
  • Сугубо асинхронный интерфейс. Единственный способ привести код в движение — запустить таймер/поток/очередь, а затем sleep() или опрашивать, потому что завершение никогда не наблюдаемо напрямую.
  • Вы меняете продакшен-код только ради того, чтобы его протестировать. Ослабляете private до public, добавляете тестовые хуки if (testMode) { ... } или наследуетесь лишь для того, чтобы добраться до внутренностей.
// Трудно тестировать: скрытые + жёстко зашитые зависимости, глобальное состояние, недетерминизм
class OrderService {
  placeOrder(cart) {
    const db = Database.getInstance();            // глобальный синглтон (нет шва)
    const gateway = new StripeGateway(API_KEY);   // жёстко зашитая конкретная зависимость -> реальная сеть
    const id = Math.random().toString(36).slice(2); // недетерминированно
    const charge = gateway.charge(cart.total);    // реальный HTTP-вызов внутри модуля
    db.save({ id, at: Date.now(), charge });       // не внедрённые часы
    return id;
  }
}
// Чтобы «протестировать это юнитом», вам придётся ходить в Stripe, глобально подменять Date/Math
// и инспектировать общий синглтон — то есть Indirect Testing через неуклюжие интерфейсы.

##Reasons for the Problem

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

  • Test-Last на легаси-коде. Мезарос называет коренной причиной отсутствие проектирования под тестируемость. Тестируемость естественно вытекает из TDD, но когда тесты пишутся «в последнюю очередь», ничто не вынуждало дизайн открывать точки управления и наблюдения, поэтому их приходится встраивать задним числом.
  • Тесная связанность и жёстко зашитые зависимости. Создание конкретных коллабораторов через new (или извлечение их из статических синглтонов) означает, что вы не можете заменить их тестовыми дублёрами — тест прогоняет и сам модуль, и всё, к чему он прикасается.
  • Глобальное/разделяемое состояние и скрытые входы. Синглтоны, статика, окружающее время, случайность и чтение окружения — это входы, которые тест никогда не объявлял и не может задать, поэтому поведение неявно и неуправляемо.
  • Асинхронность и побочные эффекты в ядре. Когда бизнес-логика переплетена с потоками, очередями, вводом-выводом или UI, не остаётся чистого значения, которое можно было бы проверить.

Чем это вредит

  • Надёжность. Тесты, вынужденные использовать реальный ввод-вывод, sleep-ы, общие синглтоны или глобальные часы, становятся медленными, нестабильными и зависимыми от порядка — классический путь к Erratic/Fragile Test.
  • Сопровождаемость. Огромная подготовка и тесты, лезущие во внутренности, привязывают набор тестов к деталям реализации, поэтому безобидные рефакторинги ломают тесты (Fragile Test / Fragile Fixture).
  • Ложная уверенность. Самые трудные и важные пути кода тестируются только через грубые косвенные интерфейсы — или пропускаются вовсе. Цифры покрытия выглядят прилично, а рискованная логика едва прогоняется.
  • Читаемость. Тест, в котором доминируют леса, скрывает то единственное поведение, которое он должен специфицировать, поэтому он ничего не документирует.

В Open Catalog of Test Smells Hard-To-Test Code — это парный со стороны продакшена аналог тестовых запахов вроде Indirect Testing и Fragile Test: плохой дизайн — причина, неуклюжие тесты — симптом.

##Treatment

Чините дизайн, а не тест. Цель — дать каждому модулю точку управления (способ привести его в известное состояние) и точку наблюдения (способ считать результат).

  1. Введите швы через внедрение зависимостей. Передавайте коллабораторов извне (внедрение через конструктор) вместо того, чтобы создавать или искать их. Внедряйте и окружающие входы — часы, генератор id/uuid, источник случайности, — чтобы они стали управляемыми параметрами.
  2. Зависьте от абстракций. Программируйте на интерфейс и подставляйте в тестах заглушку/фейк/мок. Это напрямую устраняет Hard-wired Dependency от Designite и сокращает Excessive Dependency.
  3. Примените паттерн Humble Object. Вытолкните по-настоящему трудно тестируемые части (UI, асинхронную обвязку, сырой ввод-вывод) в тонкий адаптер без логики, а логику принятия решений перенесите в простой синхронный объект, который можно тестировать напрямую.
  4. Извлеките чистые функции. Отделите вычисления от побочных эффектов; проверяйте возвращаемые значения, а ввод-вывод выполняйте только на границе.
  5. Сделайте асинхронность наблюдаемой. Возвращайте промис/future или выставляйте сигнал завершения, а также внедряйте планировщик, чтобы тесты использовали фейковые таймеры вместо sleep.
  6. Для легаси, который пока нельзя перепроектировать, используйте Test-Specific Subclass или узко ограниченный шов (приёмы Физерса), чтобы взять код под тест, а затем рефакторите в сторону внедрения — вместо того чтобы оставлять в продакшене постоянные хуки if (testMode).
  7. Избегайте глобального/статического состояния. Замените синглтоны явно передаваемыми зависимостями, чтобы тесты не делили и не сбрасывали скрытое состояние.
// ПОСЛЕ: зависимости, часы и id внедряются -> тестируемо в изоляции
class OrderService {
  constructor(db, gateway, clock = () => Date.now(), newId = uuid) {
    this.db = db; this.gateway = gateway; this.clock = clock; this.newId = newId;
  }
  placeOrder(cart) {
    const id = this.newId();
    const charge = this.gateway.charge(cart.total); // абстракция PaymentGateway
    this.db.save({ id, at: this.clock(), charge });
    return id;
  }
}

// Тест: детерминированный, без сети, без глобальных подмен, быстрый.
const svc = new OrderService(fakeDb, fakeGateway, () => 1000, () => 'id-1');
expect(svc.placeOrder({ total: 50 })).toBe('id-1');
expect(fakeGateway.charge).toHaveBeenCalledWith(50);
expect(fakeDb.saved[0]).toEqual({ id: 'id-1', at: 1000, charge: fakeGateway.result });

##Detected by