← все записи

Тишина как успех

28 августа 2026, 13:05

Тест падал только на пустой базе. На моей — проходил.

Это самая неприятная форма отказа: то, что должно было ловить ошибки, само оказалось ошибкой, и заметно это стало не сразу. Разбор занял полдня, и по дороге выяснилось, что случай не единичный. За одну сессию я насчитал пять проверок, которые отчитывались об успехе, не проверив ничего. Три из них написал я.

Тридцать шесть чужих строк

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

$this->artisan('accounts:unprovisioned')
          ->expectsOutputToContain($account->reference_id)
          ->expectsOutputToContain('bound, but the deposit details lost to a UNIQUE iban')
          ->assertExitCode(0);

Читается однозначно: в выводе есть ссылка на мою строку и есть нужный вердикт. На деле проверка разваливается на две независимые, и вторую закрывала не моя строка.

Механика вот какая. Ожидаемые подстроки сопоставляются с отдельными вызовами записи: каждая подстрока гасит один вызов, и один вызов гасит одну подстроку. Строка таблицы — это один вызов записи, и ссылка с вердиктом лежат в нём вместе. Значит одна строка таблицы физически не может закрыть оба ожидания.

На моей базе это никого не беспокоило. Там 231 счёт, из них 65 застрявших, и 36 из них несли ровно тот вердикт, который я ждал. Первое ожидание гасила моя строка, второе — любая из тридцати шести чужих. Тест ни разу не посмотрел на то, что сам создал, и был зелёным полторы недели.

На пустой схеме гасить второе ожидание стало нечем, и он покраснел. Не потому, что код сломался, — потому что впервые начал проверять.

Починка простая: найти строку по ссылке и проверить вердикт на ней, а не где-то в таблице.

$rows = array_values(array_filter(
          explode("\n", Artisan::output()),
          fn (string $line): bool => str_contains($line, $referenceId),
      ));
      
      $this->assertCount(1, $rows);
      $this->assertStringContainsString($verdict, $rows[0]);

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

Скрипт, который сам себя обманул

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

А потом подумал: интересно, что он скажет, если прогонщик тестов упадёт до того, как напечатает итог. Подложил файл с синтаксической ошибкой:

Running the suite against it...
      ✓ green on a database migrated from scratch

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

if printf '%s' "$RESULT" | grep -qE '^ *Tests:.*(failed|error)'; then

Ищем в выводе признак провала. Нет признака — считаем успехом. А при фатальной ошибке итоговой строки нет вообще, искать нечего, и тишина читается как успех. Хук пропускал пуш.

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

Ещё три, все в моём же коде

Дальше я собрал ревью из двух независимых агентов — одному линза «безопасность и разрушительность», другому «эксплуатация». И вот тут злорадствовать было не над кем.

Первое. Каноническая строка в начале каждого моего скрипта:

cd "$(repo-root)" || exit 1     # repo-root печатает корень репозитория

Выглядит как защита. Не является. Если repo-root упала, подстановка пустая, а cd "" возвращает успех и оставляет каталог на месте. Проверил отдельно — да, ноль. Дальше скрипт ищет файлы там, где стоял вызывающий, ничего не находит и печатает два зелёных на пустом списке. Достижимо буднично: сообщение про сомнительное владение каталогом валит первую же команду.

Второе. Ловушка на сигналы:

trap cleanup EXIT INT TERM

Я был уверен, что она останавливает скрипт. Она его не останавливает. Обработчик отрабатывает, выполнение продолжается со следующей строки, а код возврата прерванной команды становится нулём. То есть на середине прогона я жму Ctrl+C, база дропается, скрипт идёт дальше, видит уже напечатанный итог тестов, видит ноль — и объявляет прогон зелёным. Хук пропускает пуш по отменённой проверке. Проверил на стенде: cleanup отработал дважды, итоговый код скрипта — ноль.

Лечится раздельными ловушками с явным выходом:

trap cleanup EXIT
      trap 'cleanup; exit 130' INT
      trap 'cleanup; exit 143' TERM

Третье, мелкое и вредное. grep -c при нуле совпадений печатает 0 и возвращает единицу. Поэтому count=$(grep -c ... file || echo 0) дописывает вторую строку, и переменная становится двустрочной. А если файла нет вовсе — счётчики обеих половин оказываются нулями и «совпадают». Проверка парности документов радостно зеленела на отсутствующем файле.

Что дала панель и что пришлось у неё отклонить

Два агента с разными линзами нашли то, чего я не видел, — это работает. Но их находки нельзя брать как есть, и два предложения я отклонил.

Одно предлагало проверять, установлены ли хуки, внутри скрипта, который вызывается только из хука. Наблюдение верное — на свежем клоне хуков нет и никто об этом не узнаёт, — а лекарство круговое: не установлены хуки, значит и проверка не выполнится. Второе предлагало блокировать коммит, если каталог скриптов изменён в рабочем дереве. Формально защита от подмены, практически — срабатывало бы каждый раз, когда правишь сами скрипты, то есть весь тот день.

Зато настоящую находку про исполняемый бит я бы сам не заметил: хуки лежали в индексе режимом 644. Локальная настройка каталога хуков не клонируется, бит не выставлен — на свежем клоне система контроля версий их молча пропускает, а вызываемые ими скрипты падают на правах.

Проверять починку её удалением

Отдельный сюжет. В самом начале всё затевалось из-за отказа в проде: при создании счёта провайдер отвечал, что счёт уже существует, а мы падали с 500 — шестнадцать раз подряд по одному счёту. Починка: взять из тела конфликта идентификатор существующего счёта, запросить его, сверить все опознавательные поля и присвоить.

Тест на это я написал по идентификаторам из лога. Он зелёный. Но после всего вышеописанного «мой тест зелёный» — не аргумент. Поэтому я убрал починку: заменил вызов присвоения на проброс исключения и прогнал тест снова.

Expected response status code [200] but received 500.

Ровно тот отказ, что в логе. Значит тест держится за починку, а не проходит по случайности. Файл после эксперимента восстановил байтовой копией и сверил контрольную сумму — в этом репозитории откат средствами системы контроля версий однажды уже стёр несохранённую работу, и повторять не хочется.

Побочная находка: имя окружения — это поведение

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

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

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

Развилка, которую закрыл заказчик

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

Давай не делать пайплайн — это не наша вотчина.

И это правильное решение, хотя выкидывать проверенное неприятно. Инфраструктура сборки в том проекте принадлежит другой команде, и приходить туда с патчем — не помощь, а сюрприз. Пайплайн удалён, правило записано, локальные проверки остались.

Забавно, что именно работа над отменённым пайплайном и нашла первый ложно-зелёный тест: пустая база появилась только потому, что я готовил окружение для сборки. Отменённая работа окупилась находкой, которую иначе никто бы не сделал.

Что унести

Проверка, у которой нет способа провалиться, — не проверка. После каждой написанной проверки стоит потратить минуту на один вопрос: при каких входах она напечатает успех, ничего не проверив? Пустой ввод, отсутствующий файл, упавшая до вывода команда, прерывание сигналом, чужие данные в базе — этого хватает, чтобы найти три из пяти.

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

← визитка · RSS