Когда я начал активнее работать с ИИ-агентами для разработки, автоматических проверок в моих проектах стало больше. Это казалось однозначным улучшением: агенту проще дать воспроизводимый контракт, чем каждый раз объяснять словами, что именно нельзя сломать.

В DocOps-проекте мы постепенно собрали единый путь проверки: Make targets, CI и контрактные тесты. Если важное свойство процесса можно было проверить автоматически, я предпочитал закрепить его тестом.

В какой-то момент тесты начали проверять уже не контракт системы, а детали его текущей реализации.

Использование LLM сильно ускорило этот переход.

Всё начиналось с полезного контракта

Требования были простыми.

Например, определённая проверка должна запускаться через один канонический Make target. Он должен существовать, реально выполнять нужную команду и одинаково использоваться локально и в CI. Обходного пути с другой реализацией той же проверки быть не должно.

Для такого контрактные тесты подходят хорошо.

Они защищают не конкретную строку Makefile, а инженерную договорённость:

есть один публичный способ выполнить проверку
→ локальный запуск использует его
→ CI использует его
→ изменение структуры не создаёт обходного пути

Проблема началась, когда мы попытались доказать этот контракт слишком подробно.

Тест постепенно превратился в парсер команд

После очередного ревью находился новый гипотетический способ обойти проверку.

А если нужное имя встречается только в echo?

А если executable указан полным путём?

А если команда запускается через xargs?

А если Docker получает другой --entrypoint?

А если две команды имеют одинаковый префикс?

А что с разными вариантами quoting?

Каждый вопрос сам по себе выглядел разумно. Один агент находил очередной пограничный случай, другой подтверждал, что текущий тест его пропускает. После этого в тесте появлялось ещё немного логики и несколько новых искусственных мутаций.

В какой-то момент тесты уже содержали собственную модель shell- и Docker-команд. Они разбирали аргументы, отличали реальный вызов от строки данных, распознавали разные способы запуска executable и учитывали синтаксически разные, но эквивалентные конструкции.

Исходное требование при этом не изменилось:

нужный Make target должен существовать и запускать требуемую проверку.

Чтобы проверить несколько строк Makefile, мы фактически начали писать маленький интерпретатор shell-команд.

Тут уже стоило спросить: что именно мы защищаем?

Хрупкость может выглядеть как надёжность

Обычно хрупким называют тест, который падает после безобидного рефакторинга.

У нас происходило именно это, только внешне система выглядела всё надёжнее.

Изменение quoting могло потребовать правки теста. Эквивалентный способ вызвать ту же команду создавал новый сценарий для парсера. Ещё один допустимый вариант запуска Docker требовал научить тест понимать и его.

Наблюдаемое поведение при этом не менялось.

Мы ужесточали не контракт, а описание одной его реализации.

Чем подробнее становилась эта модель, тем сильнее тесты привязывали Makefile к текущей форме и тем дороже становились изменения, которые контракт вообще не должен был запрещать.

LLM здесь не причина, а ускоритель

Такую систему можно построить и без ИИ.

Но LLM резко снижают стоимость очередного усложнения.

Ревьюер за несколько секунд придумывает ещё пять способов обойти тест. Исполнитель так же быстро добавляет их поддержку. Следующее ревью находит новые варианты.

Получается цикл:

нашли теоретический обход
→ усложнили тест
→ добавили собственной логики
→ нашли обход уже этой логики
→ снова усложнили тест

Каждая итерация выглядит как повышение надёжности. Тестов больше, сценариев больше, отчёт агента убедительнее.

А вопрос «какое свойство системы мы защищаем?» постепенно теряется.

Агент хорошо решает локально поставленную задачу. Если попросить сделать тест сложнее для обхода, он может продолжать укреплять его намного дольше того момента, когда это перестало быть хорошим инженерным решением.

Мы удалили большую часть умной проверки

Решением оказался не более точный парсер.

Мы вернулись к исходному контракту.

Make уже умеет читать Makefile и разворачивать target. Поэтому часть собственной логики можно было заменить поведением самого Make:

make --dry-run --silent <target>

Если target не существует или не разворачивается, это обнаруживает Make. Нам не нужно частично воспроизводить его поведение внутри теста.

Остались проверки действительно важных свойств: публичных targets, канонической цепочки локального запуска и CI, обязательных зависимостей и реальных обходных путей.

Ушли проверки синтаксических вариантов, которые не меняли контракт. Вместе с ними исчезли служебные маркеры, часть парсинга и мутации, нужные в основном для проверки самого парсера.

Тестов стало меньше. Понятнее стало, что именно каждый из них защищает.

Контрактный тест не должен знать лишнего

После этой истории я стал иначе смотреть на само понятие контрактного теста.

Он не должен максимально подробно описывать устройство системы. Наоборот, хороший контракт оставляет реализации свободу меняться.

Если сегодня команда записана одним способом, а завтра другим, тест должен стать красным только тогда, когда нарушилось важное свойство системы.

Для себя я теперь использую простой вопрос:

Если реализацию изменить, полностью сохранив наблюдаемое поведение, должен ли этот тест упасть?

Если да, нужна причина, почему конкретная деталь реализации сама является частью контракта.

Иногда такая причина есть. Например, CI действительно должен вызывать канонический Make target, иначе локальные и серверные проверки могут разойтись.

Но способ расставить кавычки внутри команды таким контрактом обычно не является.

Мутационное тестирование тоже требует границы

Эта история изменила и моё отношение к мутационному тестированию (mutation testing).

Само количество мутаций, на которых тест становится красным, ещё не говорит о качестве проверки.

Хорошая мутация представляет реальное нарушение контракта.

Удалить обязательную команду — полезная мутация.

Подменить нужный target другим — полезная.

Создать реальный обход обязательной проверки — тоже полезная.

А перебирать способы синтаксически иначе записать эквивалентную shell-команду бессмысленно, если её форма не является частью контракта.

Иначе тестовая система начинает защищать саму себя.

Теперь я ограничиваю и тесты агента

Раньше в заданиях ИИ-агенту я в основном ограничивал область изменений: не делать лишний рефакторинг, не менять соседний код, добавить регрессионный тест, выполнить проверки.

Этого недостаточно.

Тест тоже может выйти за границы задачи.

Теперь меня интересует не только то, ловит ли он ошибку. Важно понять, почему эта ошибка относится к контракту и нельзя ли проверить то же свойство на более устойчивой границе.

Особенно это важно при независимом ревью другим агентом.

Формулировка:

найди способ обойти тест

легко запускает бесконечную игру в кошки-мышки.

Полезнее проверить другое:

какое свойство защищает тест
→ действительно ли он его проверяет
→ не проверяет ли он лишнего
→ можно ли подтвердить то же свойство через наблюдаемое поведение

Так ревью проверяет качество самого контракта, а не изобретательность автора теста.

Автоматизировать можно и переусложнение

Из работы с LLM я раньше вынес простой вывод: если требование можно превратить в воспроизводимую проверку, это обычно надёжнее текстовой инструкции агенту.

Я по-прежнему так считаю.

Но у этого правила появилась вторая половина.

Тест — тоже инженерное решение. Его можно переусложнить, слишком тесно связать с реализацией и потом поддерживать уже ради него самого.

ИИ-агенты делают написание тестов дешёвым. Они не делают дешёвым решение о том, что вообще стоит тестировать.

Поэтому количество автоматических проверок для меня больше не является самостоятельным признаком надёжности.

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

В DocOps мне понадобилось сначала перейти эту границу, а потом удалить часть собственных тестов, чтобы это увидеть.