Код в моих проектах сейчас пишут Codex и Grok. Я пишу спеку с критериями приёмки, агент её реализует, обкладывает тестами и сдаёт отчёт. За последние месяцы у меня набралась целая коллекция зелёных тестов, которые на самом деле ничего не проверяли. Расскажу про самые показательные случаи и про то, как я теперь принимаю работу.
Подписанная кука
Есть сервис на Go, который ставит посетителю подписанную куку вида base64(payload).base64(hmac). Агент написал к ней тест на подделку:
tampered := string(replacement) + encoded[1:] if _, err := codec.Decode(tampered, now); err != ErrInvalidSignature { t.Fatalf("tampered error = %v, want ErrInvalidSignature", err) }
Тест меняет первый символ строки. Это начало payload, кусок закодированной { (JSON-объект в base64 всегда начинается с e, отсюда и eyJ в начале любого JWT). После замены JSON перестаёт разбираться. При этом Decode на любую ошибку, хоть на кривой base64, хоть на битый JSON, отвечает одним и тем же ErrInvalidSignature. Тест получает ровно ту ошибку, которую ждал, и доволен. Когда мутация выкинула сравнение HMAC целиком, он так и остался зелёным.
Обиднее всего, что на ревью к нему не придерёшься. Выглядит разумно, да и название TestSignedCookieRoundTripAndTamperRejection вполне честное. Просто код и тест писал один агент, и одно и то же допущение попало в оба места. Сейчас вместо него два отдельных теста. В одном подпись подделана, а payload целый, в другом payload переписан, а подпись осталась старая.
Лимиты и таргетинг
В том же проекте был бюджет показов на правило. На первый взгляд там было всё: таблицы, API, форма в админке, тесты на LocalBudget.TrySpend. Только работать оно не работало. CapAvailable возвращал true константой, а TrySpend не вызывался нигде, кроме своих же тестов. Оператор ставил потолок, а трафик его в упор не видел. С таргетингом по стране и устройству та же история: отбор кандидатов был захардкожен в true.
Лимитер тестами был покрыт, а вот вызывается ли он на пути запроса, не проверял никто. Вылезло это только на сквозном прогоне.
Отчёт «all pass»
Работу агент сдаёт вместе с report.json, где по каждому критерию указаны статус и команда, которая это доказывает. В этих отчётах мне попадались и «pass» по тестам, которые вообще не запускались, и проверки с || true на конце. А моей любимой находкой стала команда go test ./... ; echo EXIT:$?. Код возврата у всей строки берётся от echo, так что там всегда ноль, чем бы ни кончились тесты.
Перезапуском такое не поймать - строка и во второй раз честно вернёт ноль. Поэтому приёмочный скрипт у меня сначала сверяет список критериев со спекой, а потом, ещё до запуска, выкидывает команды, где проверка стоит не последней или код возврата заглушён. Всё остальное он перезапускает сам и смотрит уже на свой результат.
Мутации
Мутационное тестирование тоже умеет врать. Был случай, когда мутация просто не легла. Замена через sed из-за экранирования ни с чем не совпала, файл остался прежним, и тесты фактически гонялись по исходному коду. Зелёный результат при этом засчитали как «мутант убит». Заметили только потому, что сверили sha256 файла до и после. С тех пор каждый мутационный критерий в таком случае отдельно пишет «мутация не легла».
Бывает, что мутация легла нормально, а тест её не заметил. Например, ассерт scalar < 0.5 пропустил инверсию сигнала. Значение ушло с 0.330 на 0.496 и в порог благополучно уложилось. Лечится это ассертом на конкретное значение или хотя бы на узкий диапазон.
С WordPress-плагином причина была другая: наш PHPUnit-bootstrap не подключал входной файл плагина. Поэтому выжил мутант, который убирал return после админ-баннера про отсутствующий автозагрузчик. Без этого return следующей строкой идёт require_once того самого автозагрузчика, которого нет, и плагин падает с фаталом прямо при загрузке.
Стенд
Веха браузерного сервиса прошла гейт и 31 из 31 мутации, а на стенде все 24 ячейки матрицы упали по таймауту. Как выяснилось, пакетный Chromium из Debian под непривилегированным --user 1002:1002 и без CAP_SYS_ADMIN не может поднять песочницу. Мутации такое поймать не могли в принципе, ведь они проверяют логику вокруг запуска браузера, а браузер просто не стартовал.
В проекте с кукой, кстати, восемь дефектов подряд тоже всплыли только на стенде. Два для примера. Счётчики в ClickHouse лежали голым UInt64 внутри AggregatingMergeTree, и мержи схлопывали их в произвольное значение, хотя нужен был SimpleAggregateFunction(sum, UInt64). А в AppendStruct структура передавалась по значению, тогда как clickhouse-go принимает только указатель. В итоге не записалось ни одного события, а воркер бесконечно повторял один и тот же батч.
Как я теперь принимаю работу
Сначала про ИИ-ревьюеров, их у меня на приёмке два. У одного из них как-то за день была одна находка на 14 прогонов. На одной вехе он ответил «находок нет», а второй в том же диффе нашёл пять дефектов, причём два воспроизводились одной строкой curl. Так что даже после пустого отчёта ревьюера я всё равно читаю дифф и поднимаю стенд.
Сейчас порядок такой:
Скрипт перезапускает команды из отчёта, с фильтром, про который я писал выше.
Я читаю дифф целиком.
Поднимаю стенд и прохожу сценарий руками.
Мутации по новым тестам гоняет другой агент. Если код писал Codex, мутирует Grok, и наоборот. Выживших мутантов, кроме эквивалентных, отдаю в работу как дыры в тестах.
И в тестах на отказ я теперь смотрю, откуда взялась ошибка. Тест с кукой свою ErrInvalidSignature получал исправно, только прилетала она от сломанного JSON, а проверка HMAC в этом не участвовала.