ConstructiCat Logo
CodeBust.
Browse section ▾

Только для тестов.

Боевой код содержит методы, аксессоры состояния или швы, которые существуют только для использования тестами, загрязняя реальный API и провоцируя тесты, проверяющие внутренности вместо поведения.

##Signs and Symptoms

Вы находите члены в боевом коде, чьи единственные вызывающие живут в тестовых файлах. Характерные признаки:

  • Методы, геттеры/сеттеры, экспорты или параметры конструктора, используемые исключительно из *.test.ts / *.spec.ts.
  • Имена или маркеры с тестовым «привкусом»: getStateForTest, resetForTesting, __getInternal, FTO_*, forTest, комментарии вроде // only used by tests или аннотации наподобие @VisibleForTesting / @TestOnly / @internal.
  • Ослабленная видимость (поле сделано public/экспортируемым, #private превращено в protected) исключительно ради того, чтобы тест мог добраться до внутреннего состояния.
  • Лишние «швы» — setClock(...), setRandom(...) или reset() — добавленные только потому, что они понадобились тесту, а не потому, что этого требует реальный дизайн.
// payment-service.ts  (БОЕВОЙ код)
export class PaymentService {
  #ledger: Entry[] = [];

  charge(amount: number) { /* ... */ }

  // В продакшене это никто не вызывает — только тесты:
  getLedgerForTest() { return this.#ledger; }          // раскрывает внутренности
  setClockForTest(now: () => Date) { this.now = now; } // шов только для тестов
  FTO_reset() { this.#ledger = []; }                   // "For Tests Only"
}

Быстрая проверка: grep -rn 'forTest\|ForTesting\|FTO_' src/, дающий совпадения в боевых исходниках, или прогон поиска мёртвого кода (например, Knip в продакшен-режиме), который сообщает, что экспорт не используется, хотя тест его явно импортирует.

##Reasons for the Problem

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

  • Дооснащение тестами непроверяемого кода. Когда легаси-код не проектировался под тестируемость, самый быстрый способ проверить результат — пробить дыру в SUT и прочитать его внутренности; Месарош называет это главной причиной For Tests Only.
  • Асимметричные API. Реальные клиенты используют объект в одну сторону (запись); тесты используют его симметрично (запись, а затем чтение для проверки), поэтому тестировщикам «нужны» аксессоры, которые не нужны ни одному боевому вызывающему.
  • Давление сроков. Добавить чёрный ход здесь и сейчас дешевле, чем рефакторить к дизайну, где поведение наблюдаемо через публичный контракт.

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

  • Читаемость. Публичный API больше не говорит правду — сопровождающие не могут отличить реальный контракт от тестовых лесов, и каждый читатель вынужден гадать «а используется ли этот метод вообще?»
  • Инкапсуляция и надёжность. Внутреннее состояние становится достижимым и изменяемым в поставляемом коде. Случайный вызов FTO_reset() или просочившийся сеттер может повредить состояние в продакшене; лишняя поверхность также раздувает бандл и расширяет поверхность атаки.
  • Ложная уверенность. Тесты, которые ковыряются в приватном состоянии, проверяют реализацию, а не поведение. Они могут оставаться зелёными, пока публичный контракт сломан, и ломаться при безобидных рефакторингах — хрупкие тесты, проверяющие не то.
  • Сопровождаемость. Члены «только для тестов» выглядят как мёртвый код, но их нельзя удалить; каждое изменение должно учитывать фантомных вызывающих, и запах склонен размножаться по мере того, как всё больше тестов переиспользуют этот чёрный ход.

##Treatment

Относитесь к чёрному ходу как к сигналу о дизайне, а не как к детали фикстуры.

  1. Сначала тестируйте через наблюдаемое поведение. Проверяйте возвращаемые значения, испускаемые события, сохранённый вывод или взаимодействия с коллабораторами (через тестовые дублёры) вместо того, чтобы лезть во внутренности. Большинство аксессоров getXForTest исчезают, как только вы проверяете, что объект делает, а не что он хранит.
  2. Используйте тест-специфичный подкласс (Test-Specific Subclass), когда внутренний доступ действительно нужен. Расширьте класс в тесте, чтобы раскрыть protected-член, вместо расширения видимости в боевом коде.
  3. Делайте швы частью реального дизайна, а не люками только для тестов. Внедрение часов или ГСЧ через обычный конструктор — это законное внедрение зависимостей; мутатор setClockForTest() — это запах. Если шов имеет смысл только для тестов, протолкните поведение в стратегию/Null-объект, который продакшен устанавливает по умолчанию, а тест подменяет.
  4. Если раскрытия действительно не избежать, пометьте его громко и огородите. Отметьте его как @VisibleForTesting / @internal (или используйте соглашение об именовании FTO_) и добейтесь того, чтобы боевой код никогда его не вызывал (см. детекторы). Помеченный, охраняемый шов лучше молчаливого.
  5. Выследите существующих нарушителей. Сделайте grep по forTest/ForTesting/FTO_ и запустите инструмент анализа использования/мёртвого кода, например Knip в продакшен-режиме, чтобы выявить экспорты, на которые ссылаются только тестовые файлы.
// ДО — боевой код несёт аксессор только для тестов
export class Cart {
  #items: Item[] = [];
  add(i: Item) { this.#items.push(i); }
  getItemsForTest() { return this.#items; } // только для тестировщиков
}
// тест
expect(cart.getItemsForTest()).toHaveLength(1);
// ПОСЛЕ — проверяем поведение через реальный контракт
export class Cart {
  #items: Item[] = [];
  add(i: Item) { this.#items.push(i); }
  get count() { return this.#items.length; }
  get total() { return this.#items.reduce((s, i) => s + i.price, 0); }
}
// тест
cart.add({ price: 10 });
expect(cart.count).toBe(1);
expect(cart.total).toBe(10);

Если внутренний доступ всё же нужен, сузьте шов до тестов с помощью подкласса, а не открывайте его всему миру:

// продакшен остаётся чистым: queue имеет модификатор protected, а не public
export class Scheduler {
  protected queue: Job[] = [];
  enqueue(j: Job) { this.queue.push(j); }
}
// только в тестовом файле
class TestScheduler extends Scheduler {
  peek() { return this.queue; }
}

##Detected by

  • sonar java:S5803«@VisibleForTesting»-члены не должны вызываться из боевого кода