Только для тестов.
Боевой код содержит методы, аксессоры состояния или швы, которые существуют только для использования тестами, загрязняя реальный 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
Относитесь к чёрному ходу как к сигналу о дизайне, а не как к детали фикстуры.
- Сначала тестируйте через наблюдаемое поведение. Проверяйте возвращаемые значения, испускаемые события, сохранённый вывод или взаимодействия с коллабораторами (через тестовые дублёры) вместо того, чтобы лезть во внутренности. Большинство аксессоров
getXForTestисчезают, как только вы проверяете, что объект делает, а не что он хранит. - Используйте тест-специфичный подкласс (Test-Specific Subclass), когда внутренний доступ действительно нужен. Расширьте класс в тесте, чтобы раскрыть
protected-член, вместо расширения видимости в боевом коде. - Делайте швы частью реального дизайна, а не люками только для тестов. Внедрение часов или ГСЧ через обычный конструктор — это законное внедрение зависимостей; мутатор
setClockForTest()— это запах. Если шов имеет смысл только для тестов, протолкните поведение в стратегию/Null-объект, который продакшен устанавливает по умолчанию, а тест подменяет. - Если раскрытия действительно не избежать, пометьте его громко и огородите. Отметьте его как
@VisibleForTesting/@internal(или используйте соглашение об именованииFTO_) и добейтесь того, чтобы боевой код никогда его не вызывал (см. детекторы). Помеченный, охраняемый шов лучше молчаливого. - Выследите существующих нарушителей. Сделайте
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»-члены не должны вызываться из боевого кода