devnoize справочник

CI и нестабильные тесты

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

Что такое нестабильный тест

Нестабильный тест (flaky test) — тест, который на одном и том же коде в одном и том же окружении иногда проходит, а иногда падает. Ключевые слова — «на одном и том же коде». Тест, который начал падать после изменения, не нестабилен, он сломан, и это полезный сигнал.

Причины нестабильности повторяются от проекта к проекту:

  • Зависимость от времени: тест проверяет, что операция завершилась за 100 мс, а на загруженной машине сборки она идёт 150. Или тест падает в полночь, потому что сравнивает даты.
  • Зависимость от порядка: тест полагается на состояние, оставленное другим тестом, и падает, когда порядок выполнения меняется или тесты распараллеливаются.
  • Гонки: асинхронная операция ещё не завершилась, а тест уже проверяет результат. Типичный симптом — sleep(1) в коде теста.
  • Внешние зависимости: реальная сеть, общий тестовый стенд, сторонний API с ограничением частоты запросов.
  • Неизолированные ресурсы: фиксированный номер порта, общий временный каталог, общая база данных для параллельных заданий.

Как обнаружить

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

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

bash
#!/usr/bin/env bash
# Usage: ./repeat.sh tests/test_orders.py::test_refund 50
set -u
target="$1"; runs="${2:-50}"; failed=0
for i in $(seq 1 "$runs"); do
  if ! pytest -q -p no:randomly "$target" > "/tmp/run-$i.log" 2>&1; then
    failed=$((failed + 1))
    echo "run $i failed, log: /tmp/run-$i.log"
  fi
done
echo "$failed of $runs runs failed"
[ "$failed" -eq 0 ]

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

Карантин

Жизненный цикл: тест обнаружен, помещён в карантин, исправлен владельцем и возвращён после 50 успешных прогонов; без владельца или по истечении 30 дней — удалён
Карантин — временное состояние со сроком и владельцем, а не кладбище тестов.

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

В pytest карантин удобно реализовать маркером:

toml
[tool.pytest.ini_options]
markers = [
    "quarantine(reason, owner, until): flaky test excluded from the blocking run",
]
addopts = "-m 'not quarantine'"

Основной прогон исключает помеченные тесты, а отдельное неблокирующее задание запускает только их: pytest -m quarantine. Тест помечается декоратором pytest.mark.quarantine(reason="race in refund worker", owner="payments", until="2026-05-15"). Обязательные атрибуты — владелец и срок. Без владельца тест не исправит никто. Без срока карантин превращается в склад тестов, которые никто не запускает. Простая проверка в CI может падать, если у какого-то теста в карантине истёк срок: это заставляет принять решение — исправить или удалить.

Политика повторных запусков

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

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

yaml
test:
  stage: test
  script:
    - pytest -q --junitxml=report.xml
  retry:
    max: 2
    when:
      - runner_system_failure
      - stuck_or_timeout_failure
      - scheduler_failure
  artifacts:
    when: always
    reports:
      junit: report.xml

Здесь задание повторяется только при сбое исполнителя, зависании или ошибке планировщика. Упавший тест повтора не вызывает. Если повтор на уровне тестов всё же используется, он должен быть видимым: отчёт должен показывать, что тест прошёл со второй попытки, а такие случаи — попадать в еженедельный отчёт о нестабильности.

Скорость как источник шума

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

  • Быстрые проверки — форматирование, линтеры, модульные тесты — идут первыми и в отдельном задании, чтобы простые ошибки обнаруживались за минуты.
  • Тяжёлые интеграционные тесты запускаются параллельно и только после того, как быстрые прошли.
  • Зависимости кешируются между запусками с ключом по файлу блокировки версий.
  • Тесты, которые проверяют одно и то же на разных уровнях, объединяются или удаляются.

Типичные ошибки

  • Перезапуск без чтения. Если команда привыкла перезапускать красные сборки, первым делом нужно убрать нестабильные тесты, а не уговаривать людей читать логи.
  • Необязательные проверки, которые никто не смотрит. Проверка, падение которой не блокирует слияние и не приводит ни к какому действию, — чистый шум. Её нужно либо сделать обязательной, либо убрать.
  • Общий стенд для всех. Параллельные задания, пишущие в одну тестовую базу, гарантируют нестабильность. Каждому заданию — своё изолированное окружение.
  • Вечный карантин. Список тестов в карантине, который только растёт, означает, что процесс не работает. Нужны сроки и регулярный разбор.

Итог

  • Нестабильный тест — разный результат на одном коде. Он хуже отсутствующего теста, потому что учит игнорировать CI.
  • Нестабильность обнаруживается по истории запусков и многократным прогонам.
  • Карантин — с владельцем и сроком; отдельное неблокирующее задание продолжает запускать тесты из карантина.
  • Автоматические повторы — только для сбоев инфраструктуры. Повторы тестов должны быть видны в отчётах.
  • Быстрый конвейер с ранними дешёвыми проверками уменьшает шум сам по себе.