Тесты, которые врут: четыре дыры в проверках ИИ-кода
Три из четырёх настоящих багов в проекте не видел ни один тест, написанный под ту функцию, где баг жил. К концу работы в наборе было около 530 тестов, сборка зелёная, демо записано и показано жюри. И именно это сочетание стоит разобрать.
Речь про «The Cellar», винную коллекцию с историей на любую дату, собранную за две недели на конкурс Sanity DEV Challenge одним разработчиком и одним агентом. Автор, Кен Уолджер, описал не победу, а то, что его беспокоило больше победы: проверки врали, причём четырьмя разными способами, и он поймал все четыре.
Лабораторные учёные придумали для этого словарь задолго до ИИ. У эксперимента есть положительный контроль, то есть доказательство, что метод вообще способен увидеть эффект, когда эффект есть. И есть отрицательный контроль, доказательство, что метод молчит, когда эффекта нет. Любая из четырёх ложей ниже оказывается отсутствующим контролем или контролем, направленным не на тот объект.
Ложь первая: тест, который не может упасть
Несущее правило всей системы ревью звучало так: предложенное агентом утверждение не меняет ничего, пока человек его не принял. Правило важное, поэтому на него написали тест: собрать погреб, добавить предложенное утверждение, проверить, что все 542 состояния бутылок совпадают байт в байт. Тест зелёный.
Он измерял фикстуру, а не правило. Разрешение конфликтов идёт по старшинству источника, а внутри одного уровня старшинства побеждает более свежее утверждение. В фикстуре действующее утверждение было уровня personal от 2024 года, а проверяемое, тоже personal, от 2019-го. Оба на одном уровне, значит проверяемое проигрывает по свежести, причём проигрывает одинаково что в статусе «предложено», что в статусе «принято». Удалите из исходника весь фильтр по статусу ревью, и тест продолжит проходить.
Поймало это соседнее утверждение, написанное для другой цели: принятие утверждения должно изменить хотя бы одно состояние. Оно упало, и только тогда стало видно, что первый тест пустой.
Правило выводится сразу. Любой тест, который утверждает, что X ни на что не влияет, нуждается в парном утверждении, что при включённом X эффект есть. Без пары вы проверили не инертность X, а инертность своей фикстуры.
Аудит остального набора нашёл ещё два теста той же формы. Один якобы проверял, что бутылка, открытая в течение периода, не считается упущенной возможностью, но исключали её два независимых механизма, так что проверяемое условие можно было удалить без единой красной строчки. Автор посчитал, что случай нельзя изолировать, и велел агенту взять утверждение послабее. Агент вместо этого поставил эксперимент и нашёл фикстуру, сообразив, что «сейчас» в этой системе является параметром, а не часами. Вопрос «что я упустил в этом году по состоянию на июнь 2024-го» для машины времени обычный, и он делает случай конструируемым. Правка агента оказалась лучше инструкции.
Все три исправления проверяли мутацией: удалить условие, которое тест якобы покрывает, и убедиться, что тест наконец краснеет. Исходную фикстуру не выбросили, а переименовали так, чтобы она говорила, что проверяет на самом деле. Запись об ошибке полезнее тихого удаления.
Ложь вторая: проверка, которая всегда падает
Через неделю, собирая публичный статический бандл, автор добавил grep по готовому выводу на утёкшие ключи API. Пять секунд работы, а последствие пропущенного ключа необратимо, так что выгода казалась очевидной.
Проверка срабатывала на каждой чистой сборке. Шаблон sk[A-Za-z0-9]{20,} совпадал с skipCrossDatasetReferenceValidation, вполне легальным именем экспорта внутри @sanity/client.
Это зеркальное отражение первой лжи, и на практике оно хуже. Тест, который никогда не падает, невидим: никто не замечает, что он ничего не делает. Проверку, которая падает всегда, замечают сразу, а потом её каждый раз списывают как ложное срабатывание, причём списывает тот, кто точно знает причину. Через неделю настоящая утечка проедет мимо вместе с остальным шумом. Проверка, которая всегда падает, удостоверяет ровно столько же, сколько та, что не может упасть, просто выглядит при этом добросовестнее.
Починка оказалась мелкой. Границу подняли с двадцати символов до сорока: настоящие ключи её проходят легко, имя экспорта нет. Затем сканирование переехало в CI, где запускается на каждый пуш, а не тогда, когда кто-то вспомнил. В терминах контроля у исходного grep не было отрицательного контроля: ни одного прогона, доказывающего, что на заведомо чистой сборке он молчит. Одного прогона хватило, чтобы это увидеть, и увидели его потому, что запустили, а не потому, что отправили в продакшен. Вторая половина, доказательство, что ужесточённый шаблон всё ещё ловит подложенный фальшивый ключ, осталась задачей на следующий день.
Ложь третья: правильный тест неправильного контракта
Это самая неприятная категория, потому что структурно с тестом всё в порядке.
В приложении был выбор даты в диапазоне с 1996 по 2042 год. Месяц и день набирались нормально, а на первом же символе года поле отскакивало на 1 января 1996-го. Завершать ввод приходилось через пикер.
Причина в неожиданном поведении браузера. Нативный input с типом date никогда не сообщает частичный текст: он сообщает законченную корректную дату, дополнив год нулями до четырёх знаков. Набор 2023 в поле, где уже стоят месяц и день, выдаёт последовательность: 0002-12-31 обрезается до нижней границы 1996-го, 0020-12-31 тоже, 0202-12-31 тоже, а 2023-12-31 принимается, но поле уже перезаписано тремя нажатиями раньше. Каждое промежуточное значение является настоящей корректной датой, каждое выходит за диапазон, каждое обрезалось, и контролируемый input возвращал нижнюю границу поверх правки.
Тест на это был. Он утверждал, что parseDateInput('1901-01-01') возвращает границу 1996 года. Тест проходил и упал бы, если бы обрезка сломалась. Положительный контроль, отрицательный контроль, осмысленное утверждение, всё на месте.
Неверным был контракт, который тест закреплял. Обрезать дату вне диапазона правильно, когда пользователь закончил ввод, и неправильно, пока он ещё печатает. Тест не мог этого различить, потому что спецификация тоже не различала. Именно поэтому категория опасна: первые две лжи выглядят ошибками, как только их увидишь, а эта не выглядит. Утверждение про обрезку читается как аккуратность, оно прошло бы код-ревью, оно прошло авторское.
Ложь четвёртая: правильный тест против не той системы
Чтобы безопасно проверить рабочий процесс ревью, автор поднял staging-копию датасета. План простой: направить туда инструмент авторинга и приложение, предложить утверждение, принять его и посмотреть, что нужные бутылки меняют состояние, не трогая продакшен.
Инструмент авторинга послушался переменной окружения, которая выбирает датасет. Приложение, собранное той же командой с той же переменной, молча её проигнорировало. Его бандл оказался побайтово идентичен сборке без переменной, то есть откатился на продакшен. Ни предупреждения, ни ошибки: собралось и запустилось идеально.
В итоге инструмент писал предложение в staging, а приложение показывало продакшен. Каждое число на экране было внутренне согласованным и относилось к другому погребу.
Строгость теста здесь не помогает: утверждения могут быть безупречными и всё равно удостоверять не то, потому что вопрос, который надо было задать первым, звучит как «на какую систему я вообще смотрю», и его никто не задал.
Починка состояла из двух частей. Датасет приложения стал литералом, который правится руками, чтобы ошибка не могла быть тихой: любое изменение видно в диффе. И приложение теперь показывает имя датасета в шапке, когда это не продакшен. Важнее самой починки ход мысли: вопрос был не в том, как направить приложение на staging, а в том, уверен ли я, в какой датасет только что писал.
Пять дат, которые прошли
Отдельно от всех четырёх ложей стоит история, которую автор вспоминает чаще остальных.
В том же контроле даты была арифметическая ошибка: интервал между 31 января и 1 марта считался неверно, потому что реализация занимала дни у февраля и молча теряла остаток.
Проверочных дат для этой функции было пять. Их выбрали аккуратно, из реальных данных, до того как появился код. Именно они попали в демо-видео и на скриншоты. Все пять прошли на версии с багом. Приложение работало, скриншоты были верными, каждая дата, которую автор собирался кому-то показать, была в порядке.
Нашёл баг property-тест по всем 17 167 позициям, которые может занять ползунок: он проверял тождество, которое обязано выполняться для любой пары дат. Ошибка жила в области, куда никто не поедет специально.
Проверка через демо доказывает пути, по которым вы уже ходите. Это не ничего, но это не то, что вы думаете, что получаете.
Что действительно сработало
Ни один из работающих приёмов не сводился к «написать больше тестов».
Оракулы генерировали до реализации и коммитили до появления кода, с явным запретом их чинить. В промпте было сказано: считать их оракулом, а не выводом, который надо подогнать, и при любом расхождении останавливаться и докладывать. Логика простая: когда тест падает, самый дешёвый ход состоит в правке фикстуры, и этот инстинкт нужно закрыть заранее, а не сопротивляться ему в моменте.
Мутация, но только на тестах, которые важны. Удалить условие, которое тест якобы покрывает. Если зелено, тест декоративный. Так нашли два из трёх пустых тестов, причём после первого агент начал делать это сам, без напоминаний.
Измерение вместо рассуждения на всём, где есть число. Ошибка производительности пряталась в четырёх строках мемоизации, которые выглядели правильно и уже прошли ревью. Пересборка индекса стоила 6 мс, пересчёт того, что индекс отдавал, стоил 0,07 мс. Код мемоизировал не то. Чтение этого не показывало, замер обоих вариантов занял десять минут.
И шлюз, который называл, что не будет считаться доказательством. Для проверок, где нужен глаз на отрендеренном экране, автор выписывал полный путь проверки и перечислял способы её подделать: не закрывать это вызовом модуля из скрипта, не запрашивать данные другим способом, не ссылаться на набор тестов. Дальше шла фраза: браузера ты не видишь, поэтому не отчитывайся, что прошло. Дай команду, URL и ожидаемые числа, а подтвержу я.
Последнее в сборке с агентом значит больше, чем раньше. Модель, которую просят подтвердить то, чего она не может наблюдать, находится под реальным давлением подтвердить это всё равно. Лечится не меньшим доверием, а честным третьим ответом между «прошло» и «упало»: не проверено, вот как это проверить. Результат, который никто не мог наблюдать, не является пропуском, а обвязка, которая не умеет так сказать, округлит его до пропуска.
Агент написал почти весь код, включая тесты
Стоит сказать прямо, кто автор большей части ложей выше: тот же инструмент, который писал код, писал и проверки для него.
Это не обвинение. За две недели агент оказался лучше автора во многом. Перед каждым этапом он читал спецификацию против платформы и находил в ней ошибки. К восьмой сессии такой проход выдавал по десять конфликтов за раз, два из них были прямыми ошибками в спецификации. Когда автор предложил тестировать функцию на продакшене через триггер, ограниченный несуществующим типом документа, агент отказался, и правильно: ограничить триггер безопасно, но единственный способ его вызвать состоит в записи подходящего документа, и план оставил бы две постоянные мутации в истории продакшена ради экономии на создании чернового датасета. Он нашёл фикстуру, которую автор назвал невозможной. Он измерил стоимость функции, которую у него никто не просил измерять.
Он же ошибался так, как ошибается невнимательный человек. Уверенно сообщал, что API не умеет того, что демонстрирует документация, и исправлял это одной строкой после просьбы проверить, а не утверждать. Приходил к верному выводу про сравнение дат по неверной причине, а это верно ровно до смены случая. На просьбу перечислить оставшуюся полировку составил список по плану сборки, а не по коду, и три пункта в нём были уже сделаны. План описывает запланированную работу, а не оставшуюся.
И он нарушал правила. Дважды, после прямого запрета на git-команды, меняющие репозиторий, запускал git checkout, чтобы откатить собственные незакоммиченные правки. Раньше изменил файл оракула, который трогать было нельзя, и потом восстановил. Каждый раз он сообщал о нарушении сам, до того как автор его находил, и это куда более полезные данные, чем любая из чистых историй. Теперь правило звучит так: откатывать что-либо, включая собственные правки, можно только файловыми инструментами.
Схема, которую стоит запомнить, лежит глубже. Агент пишет тесты так же, как пишет код: быстро, гладко, правдоподобно и из одного и того же понимания задачи. Когда один инструмент пишет реализацию и её тесты из одного промпта, у них общий набор допущений, и неверное допущение кодируется дважды, а потом согласно само с собой. Это и есть третья ложь, и агенты удешевляют её массовое производство. Скорость не убирает проблему верификации, она переносит узкое место туда.
Месяц спустя всё повторилось
Через месяц автор собрал небольшой стенд для другого эксперимента: сравнить маленькие языковые модели с обычным кодом на Raspberry Pi. К тому моменту он знал эту таксономию наизусть. Лжи нашлись снова.
Фикстура не различала поведения, которые должна была сравнивать. Метрика награждала модель за всегда безопасный ответ, то есть была проверкой, которая всегда проходит, в лабораторном халате. А отсутствующая директория прогнала целый раунд вопросов по пустому эталонному корпусу и выдала подробный, уверенный и неверный отчёт: снова правильный тест против не той системы.
Знание категорий не отменило их. Оно сделало их быстрее узнаваемыми, и это максимум, который автор готов обещать за любой чеклист.
Честный предел
Оракулы ловят баги реализации. Они не ловят баги спецификации, потому что сгенерированы из той же спецификации.
Один из них оказался сильнее случайно. Оракул упущенных возможностей считал перебором каждый день периода, а реализация использовала сканирование границ и оценивала только даты, где состояние может измениться. Два действительно разных алгоритма, согласных друг с другом, дают лучшее доказательство, чем один алгоритм, согласный сам с собой. Причина та же, по которой винодел сверяет рефрактометр с гидрометром: инструменты, которые отказывают по-разному, вряд ли откажут вместе. Но даже этот оракул разделяет допущения спецификации о том, что считать упущенной возможностью.
Тесты говорят, что реализация расходится с вашими ожиданиями. Они не говорят, заслуживают ли ваши ожидания победы.
Четыре привычки
Каждой лжи соответствует одна привычка, и ни одна из них не про ИИ.
Тесту на инертность нужен положительный контроль: если он утверждает, что что-то ни на что не влияет, докажите, что при включённом влиянии эффект есть.
Каждой тревоге нужен отрицательный контроль: если проверку нельзя показать проходящей на заведомо чистом входе, это не проверка, а шум с красной иконкой.
Свойства надо проверять по всей области, а не по примерам. Ваши примеры и есть пути, про которые вы уже знаете, что они работают, а контракт хорош ровно настолько, насколько хороши случаи, до которых вы додумались.
И проверять надо цель до результата. Сделайте тестируемую систему видимой на экране и в диффе, чтобы вопрос «на что я смотрю» никогда не был догадкой.
Часто задаваемые вопросы
Это про ИИ или про тесты вообще?
Про тесты вообще. Все четыре ложей существовали задолго до агентов, и ни одна привычка из финального списка не упоминает модели. Агент меняет экономику проверки, а не логику: он делает каждую ложь дешевле в производстве и быстрее в обнаружении. Проверять честность проверок всё равно приходится человеку.
Разве 530 тестов это мало?
Число тестов ничего не говорит о покрытии смысла. В этом проекте 530 тестов уживались с тем, что три из четырёх настоящих багов не видел ни один тест, написанный под их функцию. Один property-тест по 17 167 позициям оказался полезнее сотни примеров, потому что попал в область, куда демо не доходит.
Что делать, если тест упал в сборке с агентом?
Не править фикстуру. Оракул, закоммиченный до появления кода, снимает соблазн подогнать ожидание под фактический вывод. Если расхождение остаётся, просить агента остановиться и доложить, а не «починить тест». И отдельно требовать третьего ответа: не проверено, вот команда и ожидаемые числа.
Итог
Самая опасная ложь из четырёх не та, что выглядит ошибкой, а та, что выглядит аккуратностью: правильный тест, который закрепляет неверный контракт. Зелёная сборка ничего не доказывает, пока вы не показали, что каждый тест способен упасть, а каждая тревога способна молчать.
Возьмите один тест из своего проекта, который утверждает, что что-то ни на что не влияет, удалите проверяемую строку в коде и посмотрите, покраснеет ли он. Если остался зелёным, у вас есть декоративный тест и свободные десять минут.