Привет, Хабр!
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». Записаться
Больше бесплатных уроков сентября смотрите в дайджесте.

