В репозитории ashikov.ru есть единая команда make check. Она запускает обязательные проверки и собирает сайт. При разборе этих проверок нашлись два случая, когда они могли завершиться успешно, не выполнив обещанный контракт.

В одном случае commitlint пропускал некорректное сообщение коммита из-за слишком широкого исключения. В другом скрипт проверки окончаний файлов мог принять ошибку получения списка файлов за отсутствие нарушений.

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

Ошибка Git превращалась в успешную проверку

Скрипт получал список отслеживаемых файлов через git ls-files и передавал его в цикл с помощью подстановки процесса. Если убрать саму проверку окончаний файлов, оставив получение списка и счётчик, структура выглядела так:

set -euo pipefail

checked=0
while IFS= read -r -d '' entry; do
  checked=$((checked + 1))
done < <(git ls-files --cached --stage -z)

printf 'Checked endings of %d tracked Git text files\n' "$checked"

При повреждённом Git-индексе git ls-files завершался с ошибкой. Цикл не получал записей, счётчик оставался нулевым, а основной скрипт печатал сообщение об успехе и возвращал код 0.

Проблема была не в проверке переводов строк. Она возникала раньше — при подготовке входных данных.

Конструкция <(...) запускает процесс асинхронно. В этой форме код завершения git ls-files не становился кодом завершения основного скрипта. Наличие set -euo pipefail не обеспечивало нужной передачи ошибки.1

Пустой список сам по себе не обязательно является ошибкой. В репозитории действительно может не оказаться подходящих файлов. Но «список успешно получен и пуст» и «список получить не удалось» — разные результаты.

Получение входных данных стало отдельным шагом

Вместо подстановки процесса список стали записывать во временный файл:

inventory=$(mktemp)
trap 'rm -f -- "$inventory"' EXIT
git ls-files --cached --stage -z >"$inventory"

После этого цикл читает готовый файл. При действующем set -e ошибка самостоятельного вызова git ls-files останавливает скрипт до проверки содержимого и сообщения об успехе.

Регрессионный тест создаёт временный Git-репозиторий, а затем через GIT_INDEX_FILE подставляет повреждённый индекс. От скрипта требуется ненулевой код завершения и отсутствие сообщения Checked endings.

Так проверяется не форма shell-кода, а нужное поведение: невозможность получить входные данные не должна превращаться в успешную проверку.

Исключение для fixup-коммитов оказалось слишком широким

В конфигурации commitlint была другая ошибка:

ignores: [(message) => message.includes("fixup!")],

Функция в ignores разрешает пропустить сообщение целиком.2 Но includes искал fixup! в любом месте, а не только в начале сообщения.

Поэтому исключению соответствовал и такой некорректный заголовок:

invalid commit mentions fixup! in prose

Достаточно было упомянуть fixup! даже в теле сообщения, чтобы заголовок перестал проверяться. Исключение для одного вида служебных коммитов распространялось на посторонние сообщения.

Исправление сузило условие:

ignores: [(message) => message.startsWith("fixup!")],

В тестах появились обе стороны границы. Настоящий fixup!-коммит должен проходить, а некорректный заголовок с упоминанием этого маркера в обычном тексте — отклоняться. Отдельно проверяется корректное сообщение docs: explain fixup! commits: само упоминание маркера не является нарушением.

Тест должен подтвердить нужную причину отказа

Проверить только ненулевой код иногда недостаточно. Команда может не запуститься, не загрузить конфигурацию или упасть по причине, не связанной с проверяемым правилом.

Поэтому регрессионный тест commitlint проверяет и код завершения, и диагностику type-empty для некорректного заголовка. Тест повреждённого индекса требует ошибки Git и отдельно запрещает итоговое сообщение об успешной проверке файлов.

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

Получаются три различимых исхода:

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

Тестировать поведение, а не написание проверки

В статье «Когда контрактный тест знает слишком много» я описывал, как тесты начали разбирать детали shell-команд вместо проверки контракта. Здесь не понадобился ещё один такой парсер.

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

Эти сценарии входят в make test-checks, который добавлен в общий make check. Они не остались отдельной командой, о которой нужно вспоминать после изменения проверок.

Два дефекта показали разные способы получить ложный успех. В первом проверка теряла ошибку подготовки данных. Во втором собственное исключение не давало ей проверить сообщение.

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