Тестовая логика в продакшен-коде.
Продакшен-код содержит логику, ветви или члены, существующие лишь для поддержки тестирования, что размывает грань между тем, что попадает в продакшен, и тем, что просто тестируется.
##Signs and Symptoms
Вы находите в тестируемой системе (SUT) код, который имеет значение, только когда выполняется тест. Характерные признаки:
- Тестовые хуки / флаги режима — ветви, завязанные на флаг
testing,isTest,NODE_ENV === 'test'илиmock, которые замыкают накоротко настоящее поведение. - Члены «только для тестов» — публичные сеттеры, геттеры,
reset()или конструкторы, добавленные исключительно для того, чтобы тест мог добраться до внутреннего состояния, нередко помеченные@VisibleForTesting/@TestOnly, а затем реально вызываемые из продакшена. - Загрязнение сравнением — логика
equals()/ сравнения, добавленная в продакшен-класс лишь для того, чтобы проверка могла сравнить два объекта. - Зависимость от тестов в продакшене — продакшен-модули, импортирующие тестовый фреймворк, фикстуру или фабрику моков.
// ЗАПАХ: поставляемый код ведёт себя иначе, когда включён «testing»
class PaymentService {
charge(order: Order) {
if (process.env.NODE_ENV === 'test' || this.isTesting) {
return { status: 'ok', id: 'FAKE-TEST-ID' }; // реальный шлюз никогда не задействуется
}
return this.gateway.charge(order); // <-- путь, который реально уходит в продакшен
}
}
«Настоящая» ветвь — это та, в которую попадают ваши клиенты, и именно её пропускают ваши тесты.
##Reasons for the Problem
Почему это происходит
- Тестируемую систему трудно тестировать (она обращается к сети, часам, платёжному шлюзу или файловой системе), и добавить заглушку
if (testing)быстрее, чем ввести нормальный шов. - Тесту нужно наблюдать или задавать внутреннее состояние, и разработчик открывает к нему доступ «только ради теста».
- Создавать заглушки/моки неудобно, поэтому подставные данные жёстко зашиты за флагом.
Чем это вредит
- Ложная уверенность. Тесты прогоняют только тестовую ветвь, поэтому продакшен-путь уходит в релиз непроверенным. Зелёные тесты доказывают, что работает подделка, а не настоящее поведение.
- Надёжность / безопасность. Тестовый код, доживший до продакшена, может сработать всерьёз. Хрестоматийный предостерегающий пример у Месароша — Ariane 5: оставленный активным в полёте наземный код спровоцировал катастрофу. Оставленный включённым
if (isTesting)— программная версия того же самого. - Безопасность. Тестовые обходы — это бэкдоры: флаг, пропускающий аутентификацию, оплату или валидацию, отделён от эксплуатируемой уязвимости всего одной ошибкой конфигурации.
- Читаемость и раздувание API. Тестовые сеттеры/геттеры/
reset()расширяют публичную поверхность и вводят реальных клиентов в заблуждение относительно назначения класса. - Сопровождаемость. В одном классе живут два поведения; при каждом изменении приходится держать в голове и продакшен-путь, и тестовый, а их расхождение тихо загнивает.
##Treatment
Вынесите тестовую логику из продакшен-кода, введя нормальный шов вместо флага.
- Внедрите вариативность (внедрение зависимости + тестовый дублёр). Замените жёстко зашитую ветвь коллаборатором, которого тест подменяет. В продакшене подключается настоящая реализация; в тесте — фейк/стаб/мок.
- Используйте тестовый подкласс (Test-Specific Subclass), когда нужно переопределить лишь один метод — переопределите его в подклассе, который живёт в тестовом коде, а не через
ifв базовом классе. - Примените паттерн Humble Object, чтобы вынести трудно тестируемую логику (часы, ввод-вывод) за тонкий адаптер, и тогда основная логика станет напрямую тестируемой без хуков.
- Перенесите логику сравнения на сторону теста. Вместо того чтобы загрязнять продакшен методом
equals()ради проверок, используйте собственный матчер/компаратор или проверяйте только интересующие вас поля. - Не пускайте тестовый код в сборку. Используйте разделение по source-set / конфигурации сборки, чтобы тестовые помощники нельзя было скомпилировать в поставляемый артефакт; помечайте действительно видимые для тестов члены аннотациями
@VisibleForTesting/@TestOnlyи поручите линтеру следить, чтобы продакшен их никогда не вызывал.
// ДО: тестовый хук внутри продакшен-кода
class PaymentService {
charge(order: Order) {
if (this.isTesting) return { status: 'ok', id: 'FAKE-TEST-ID' };
return this.gateway.charge(order);
}
}
// ПОСЛЕ: единственный путь; шлюз внедряется и подменяется в тесте
class PaymentService {
constructor(private gateway: PaymentGateway) {}
charge(order: Order) {
return this.gateway.charge(order); // один и тот же путь в продакшене и тесте
}
}
// тест
const fakeGateway = { charge: () => ({ status: 'ok', id: 'FAKE-TEST-ID' }) };
const service = new PaymentService(fakeGateway);
Теперь продакшен-путь — это единственный путь, а тест управляет поведением снаружи.
##Detected by
- codeql java/visible-for-testing-abuse — Использование VisibleForTesting в продакшен-коде
- deepsource JAVA-A1067 — Методы @VisibleForTesting/@TestOnly не должны использоваться в нетестовом коде
- android-lint VisibleForTests — Видимый только для тестов