Привет, Хабр!

Flaky‑тест даёт разные результаты на одном и том же коде.

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

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

Инструменты за последние пару лет заметно подтянулись.

Раннеры научились отличать «прошёл со второй попытки» от «прошёл», CI‑платформы завели карантин с политиками и сроками, Playwright в версиях 1.62 и 1.63 добавил изолированные ретраи и локи на общий ресурс.

Механики есть, а привычки в командах остались прежние, и именно они превращают одиночный флейк в постоянного жильца.

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

Ретрай, который прячет настоящий баг

Самая частая реакция на нестабильный тест — включить повторы.

--reruns 3 в pytest, retries: 2 в конфиге Playwright, автоматический ретрай на стороне CI‑платформы.

Красный цвет пропадает в тот же день, и это единственное, что делает такое решение похожим на рабочее.

Ретрай докладывает результат последней попытки.

Тест, упавший из‑за гонки в приложении, на втором заходе эту гонку выигрывает и сообщает passed.

Сама гонка остаётся в коде, и однажды проиграет её уже не тест.

Playwright исходы разделяет на:

  • passed;

  • flaky;

  • failed.

Прошедший со второй попытки тест помечается жёлтым и попадает в отчёт отдельной категорией.

Только CI смотрит на код возврата, а код возврата зелёный, поэтому жёлтую категорию не видит никто.

Сузить ретрай по типу ошибки стоит первым делом.

Повторять имеет смысл то, что действительно бывает транзиентным:

  • обрыв соединения;

  • 502 от стенда;

  • таймаут DNS.

@pytest.mark.flaky(reruns=3, only_rerun=["ConnectionError", "ReadTimeout"])
def test_export_report(client):
    ...

only_rerun и rerun_except в pytest‑rerunfailures принимают регулярки по тексту исключения и перекрывают глобальные --only-rerun и --rerun-except.

Ассерт в белый список не входит никогда: если упала проверка, повтор её не воскрешает, а прячет.

Дальше флейк надо сделать громким.

В pytest‑rerunfailures с версии 15.0 есть --fail-on-flaky, который завершает прогон ненулевым кодом, когда хоть один тест прошёл только с повтора.

У Playwright ту же роль играет failOnFlakyTests в конфиге и одноимённый ключ командной строки.

Сборка после этого падает, но падает по понятной причине, и находка не теряется в отчёте, куда никто не заходит.

Тоньше история с тем, где именно выполняется повтор.

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

В Playwright 1.62 для этого появился retryStrategy: 'isolated', все повторы уезжают в конец прогона и выполняются по одному в единственном воркере.


sleep(2) в роли синхронизации

Вторая привычка растёт из вроде хорошего побуждения дождаться.

Клик по кнопке, в фоне уходит запрос, ответ приходит через десятые доли секунды.

В тест едет time.sleep(2), локально всё стабильно, дальше репозиторий.

Двойка тут работает ставкой на скорость машины.

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

Те же действия занимают там 2,3 секунды, и тест падает, но не всегда, а когда сосед собирает фронтенд.

Исправляется обычно банальной двойкой в пятёрку.

Через год у вас сорок минут прогона, половина которых уходит на сон, и он всё ещё иногда падает.

Ждать надо условие, а не время:

def wait_until(predicate, timeout=5.0, interval=0.05):
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if predicate():
            return
        time.sleep(interval)
    raise AssertionError(f"условие не выполнилось за {timeout} с")

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

В браузерных тестах это сделано за вас.

Веб‑ассерты Playwright сами перепроверяют условие, пока оно не выполнится или не кончится таймаут (по умолчанию пять секунд), expect.toPass() повторяет целый блок проверок, expect.poll() — произвольное значение.

Каждый page.waitForTimeout() по соседству с ними означает, что кто‑то не поверил в автоожидание.

Чего точно не стоит делать, так это поднимать глобальный таймаут ассертов до тридцати секунд.

Гонка выигрывается или проигрывается в первые полсекунды, флейк так не лечится.

Зато каждое падение начинает стоить полминуты.

Общее состояние и порядок запуска

Есть отдельный сорт флейков, который выдаёт себя фразой «а по отдельности всё проходит».

  • Запускаешь pytest tests/billing/test_invoice.py::test_totals — зелено.

  • Запускаешь всю пачку — красно.

Виноват при этом не упавший тест.

Наследил сосед, отработавший раньше:

  • строка в базе;

  • залогиненная сессия;

  • подменённый синглтон;

  • файл /tmp/report.csv с фиксированным именем;

  • закешированный конфиг.

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

Ловятся перемешиванием порядка.

pytest‑randomly тасует модули, затем классы, затем функции, печатает базовый seed в шапке прогона и умеет повторить последнюю раскладку по --randomly-seed=last.

$ pytest
Using --randomly-seed=1553614239
...
$ pytest --randomly-seed=1553614239 tests/billing/

Дальше сужаете каталог, пока не останется пара тестов, ломающих друг друга.

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

Параллельный прогон часть проблемы решает бесплатно.

pytest -n auto поднимает отдельные процессы, поэтому модульные глобалы и синглтоны у каждого воркера свои.

А вот что по процессам не делится — база, файлы, порты, внешние сервисы, то и остаётся полем боя:

два воркера берут одну запись, падает тот, кто пришёл вторым.

Общий ресурс либо изолируют по воркерам (своя схема или своя база на worker_id), либо честно объявляют общим и не дают тестам разъезжаться:

@pytest.mark.xdist_group("payments")
def test_refund_flow(...):
    ...

С --dist loadgroup все тесты одной группы гарантированно уезжают в один воркер.

В Playwright 1.63 для того же случая завезли локи: тест объявляет lock: 'user-settings', и тесты с одинаковым именем лока не выполняются одновременно ни в разных файлах, ни в разных воркерах, ни в разных проектах.

Остальной прогон продолжает идти параллельно, чем лок и отличается от глобального workers: 1, которым обычно затыкают такие места.

Время, случайность и локаль как скрытые входы

Тест был зелёным полгода и упал тридцать первого марта.

Или в 00:03.

Или только в ночной сборке.

Такие тесты берут на вход то, чего никто не объявлял:

  • datetime.now();

  • random;

  • таймзону раннера;

  • локаль;

  • порядок обхода множества.

Проверка «отчёт за текущий месяц» ломается в последний день месяца.

Проверка created_at.date() == date.today() ломается, когда прогон пересекает полночь.

timedelta(days=1) в локальной зоне не равен двадцати четырём часам в день перехода на летнее время: сутки там длятся 23 или 25 часов, и арифметика, одиннадцать месяцев работавшая правильно, перестаёт сходиться.

А еще бывают множества строк.

Хеш строк рандомизируется при старте интерпретатора, поэтому list(tags)[0] от запуска к запуску возвращает разное, и проверка «первый элемент» падает примерно раз в N прогонов, где N зависит от размера множества.

Правильный ход — сделать скрытый вход явным.

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

def build_report(orders, *, now):
    period = now.replace(day=1)
    ...

def test_report_on_month_end():
    build_report(orders, now=datetime(2026, 3, 31, 23, 59))

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

Заморозка нужна там, где до вызова now() уже не дотянуться:

  • freeze_time("2026-03-31") из freezegun;

  • time_machine.travel();

  • фикстура freezer из pytest‑freezer с методами move_to и tick.

У фикстуры есть неочевидное преимущество перед голым декоратором freezegun — она накрывает и тот код, который выполняется внутри других фикстур, а декоратор до него не достаёт.

В браузере ту же работу делает Clock API Playwright: page.clock.install(), pauseAt(), fastForward().

Случайность фиксится сидированием, и здесь снова поможет pytest‑randomly.

Он сбрасывает random.seed() перед каждым тестом, а заодно приводит в известное состояние factory_boy, Faker, numpy, PyTorch и TensorFlow, если они установлены.

Значение выводится из базового seed и идентификатора теста, так что данные у каждого теста свои, но воспроизводимые.

Таймзону и локаль в CI лучше зафиксировать явно, через TZ=UTC в окружении джобы, а сверху добавить одну ночную сборку с намеренно сдвинутой зоной и датой около границы месяца.

Флейки, которые никто не считает

Спросите в команде, сколько у вас нестабильных тестов.

Ответ «ну, парочка есть» означает, что не считает никто.

Считается флейкость просто:

одинаковый коммит, разные исходы.

Минимальная версия не требует ничего покупать.

Собирайте JUnit XML со всех прогонов, ключом берите идентификатор теста плюс SHA, и любой тест, у которого на одном SHA есть и pass, и fail, попадает в список.

Рядом кладите две метрики:

  • сколько сборок он уронил;

  • сколько минут прогона стоил.

Готовые инструменты добавляют к списку жизненный цикл.

В Test Optimization у Datadog есть отдельная страница управления флейками с состояниями:

  • quarantined — тест продолжает выполняться, но его падения не роняют пайплайн;

  • disabled — тест не запускается вообще.

Оттуда же стоит утащить early flake detection.

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

У карантина обязаны быть владелец и срок.

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

И подбирать всё подряд карантин тоже не должен.

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

  • 22 сентября в 20:00. «Playwright JS: как быстро начать писать автотесты?». Записаться

  • 29 сентября в 20:00. «Модульные тесты YaxUnit в связке с EDT». Записаться

Больше бесплатных уроков сентября смотрите в дайджесте.