Почему я требую увидеть новый тест красным

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

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

Меня как руководителя интересует не только качество отдельного теста. В какой‑то момент мне всё равно приходится отвечать на более практичный вопрос: могу ли я принимать решение о релизе, глядя на зелёный CI?

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

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

11 сентября я за один день столкнулся в собственном проекте с тремя ситуациями, где зелёный сигнал этого не гарантировал. Все три связаны с кодом, который писал я сам.

Ниже три случая и правило, которое после них появилось у нас в работе.

Оговорка про код: в проекте эти случаи произошли на разных языках — сборочный гейт на shell, пререндер на JS, проверка учебного задания на Python. Для статьи и стенда я свёл все три к Go, сохранив механику каждого. Запускается одной командой, без внешних зависимостей.

Случай первый. Гейт не различал сломанную сборку и исправную

После каждой выкладки фронтенд сверяет свою версию с версией на сервере и, если они разошлись, показывает сообщение «доступна новая версия, обновите страницу».

В тот день это сообщение увидел каждый посетитель. Нажимаешь «обновить», страница перезагружается, сообщение появляется снова. И так по кругу.

Причина оказалась простой: сборщик писал в один файл короткий хеш коммита 551eab4, а в сам бандл зашивал строку вида 2026-09-11T17-09-33-208Z-551eab4. Совпасть они не могли.

Я исправил сборку и добавил гейт: версия должна присутствовать в собранном бандле.

func gateSubstring(bundle, version string) bool {
	return strings.Contains(bundle, version)
}

Прогоняем на обеих сборках:

проверка               сломанная сборка   корректная сборка
подстрока              ПРОШЛА             ПРОШЛА
Короткий хеш 551eab4 входит хвостом в длинную версию из бандла

Короткий хеш 551eab4 входит хвостом в длинную версию из бандла

Короткий хеш входит в длинную версию хвостом. Гейт зелёный в обоих случаях. Он не различает два состояния, ради которых и был написан.

В этом примере достаточно потребовать, чтобы версия встречалась в бандле отдельным строковым значением:

func gateValue(bundle, version string) bool {
	return strings.Contains(bundle, `"`+version+`"`)
}
значение в кавычках    упала              ПРОШЛА

После этого гейт начинает различать состояние, которое я хотел контролировать.

Случай второй. Проверка была не там, где ломалось

В проекте 272 урока, и они ссылаются друг на друга. В исходниках ссылка выглядит как ./06-indexes.md, а на сайте должна превратиться в /course/sql/indexes.

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

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

func renderInBrowser(href, track string) string {
	return fmt.Sprintf(`<a href="%s">…</a>`, resolveHref(href, track))
}

func renderStatic(href, track string) string {
	return fmt.Sprintf(`<a href="%s">…</a>`, href) // резолвер не вызван
}

В браузере ссылки работали: приложение после загрузки расставляло правильные адреса. Статический HTML для поисковых роботов собирался другим путём, и на этом пути resolveHref не вызывался.

Что видит пользователь:      <a href="/course/sql/indexes">…</a>
Что видит поисковый робот:   <a href="./06-indexes.md">…</a>

1335 ссылок вели в никуда. Полгода. Видели это только роботы, а у роботов не принято жаловаться в поддержку.

Тесты покрывали функцию resolveHref, а отвечали мы за итоговый HTML на обоих путях

Тесты покрывали функцию resolveHref, а отвечали мы за итоговый HTML на обоих путях

Здесь важен другой уровень проверки. Локальные тесты были корректными, но мы контролировали функцию, а отвечали за итоговый HTML.

До вопроса «способна ли проверка покраснеть?» есть ещё один: то ли состояние системы мы вообще проверяем?

Ловится это уже на результате:

func gateOnOutput(render func(string, string) string) bool {
	return !strings.Contains(render("./06-indexes.md", "sql"), ".md")
}
проверка результата     в браузере      в статическом HTML
нет '.md' в HTML        ПРОШЛА          упала

Случай третий. FAIL напечатан, наружу ушёл зелёный сигнал

Проверка учебного задания печатала FAIL, объясняла, что именно не сошлось, и завершалась с кодом ноль. CI видел успешный процесс и шёл дальше.

passed := true

check := func() {
	result := 2 + 2
	if result != 5 {
		fmt.Println("FAIL: ожидали 5, получили", result)
		passed := false // := создаёт НОВУЮ переменную и затеняет внешнюю
		_ = passed
	}
}
check()

if passed {
	os.Exit(0) // сюда и попадаем: внешняя passed так и осталась true
}
os.Exit(1)

Внутри замыкания := объявило новую переменную и затенило внешнюю. Внешняя так и осталась true. Разница между «сигнал верный» и «сигнал врёт» — один символ, = вместо :=, но для CI она принципиальная:

как было написано:
FAIL: ожидали 5, получили 4
код возврата: 0
CI видит ЗЕЛЁНОЕ

после исправления:
FAIL: ожидали 5, получили 4
код возврата: 1
CI видит красное
Проверка нашла ошибку и напечатала FAIL, но наружу ушёл код 0 и CI увидел зелёное

Проверка нашла ошибку и напечатала FAIL, но наружу ушёл код 0 и CI увидел зелёное

Сообщение об ошибке печаталось и раньше. Но зелёный статус сам по себе не заставляет человека идти в лог и искать там слово FAIL.

Что у этих трёх случаев общего

В первом случае проверка стояла в нужном месте, но не различала сломанную и исправную сборку.

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

В третьем проверка увидела ошибку, но наружу передала успешный exit code.

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

Ни один из этих случаев не был пойман на ревью. Из этого не следует, что code review бесполезен. Первую и третью ошибку внимательный ревьюер вполне мог заметить глазами.

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

С покрытием похожая история. Даже стопроцентное покрытие строк не доказывает, что проверка чувствительна к нужному дефекту. Coverage отвечает на полезный вопрос, но не на этот.

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

  1. мы проверяем то состояние системы, за которое на самом деле отвечаем;

  2. эта проверка способна покраснеть на нужном дефекте.

Стандарт, который я после этого поставил

У нас правило теперь формулируется так:

Сначала проверь, что тест стоит на нужной границе. Потом сломай то, что он обязан поймать, и убедись, что тест падает по ожидаемой причине.

Это не требование мутировать всё подряд. Оно касается проверок, которым мы собираемся доверить конкретную регрессию.

Тест написан после починки дефекта. Код уже исправлен, поэтому новый тест с первого запуска зелёный. Первый и третий примеры именно такие.

Мы отвечаем за результат, который не наблюдаем напрямую. Во втором случае тестировали функцию, а ответственность была за HTML, который реально уходил пользователю и поисковому роботу.

Ближе всего к этому подходу мутационное тестирование. Там инструмент сам меняет код и считает, сколько мутантов поймали тесты. Для минимального командного правила отдельный mutation framework не обязателен. Для такого локального случая достаточно вручную вернуть дефект или сделать эквивалентную мутацию и посмотреть на результат.

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

Зачем это руководителю

Решение о релизе всё равно принимается по агрегату.

Я вижу, что сборка зелёная, проверки прошли, quality gate не упал. По этой информации нужно решить, можно ли выкатывать.

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

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

И раз я на этот сигнал опираюсь, отвечать за его качество мне, а не автору отдельного теста.

Сам проект — backendstart.ru, те самые 272 урока, в которых 1335 ссылок полгода вели в никуда.

И последнее, про честность стенда

Четвёртым случаем сюда просилась гонка потоков. В учебном задании решение принималось без блокировки, а окно гонки при стандартном интервале переключения не открывалось.

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

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

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

На авторе этого правила оно работает точно так же.

Автор: dixmod

Источник

Оставить комментарий