Как ловить flaky-тесты автоматически: метрики и retry-стратегии
Flaky-тест — самый дорогой тип бага в QA. Он не падает достаточно часто, чтобы его починили, но падает достаточно часто, чтобы команда теряла к набору доверие. Через полгода начинается классический сценарий: «прогон красный — перезапусти, обычно проходит», и пайплайн превращается в лотерею.
За семь лет в автотестах разбирала flaky на проектах с 50 тестами и на проектах с 12 000. Подход одинаковый: автоматическое выявление, метрика на каждый тест, карантин для безнадёжных. Без автоматики flaky размножаются быстрее, чем чинятся.
Что считать flaky
Технически flaky — это тест, который при идентичном коде продукта и теста даёт разный результат. На практике важна частота: один сбой на тысячу прогонов — статистический шум, один на десять — серьёзная проблема.
Использую такие границы:
- flaky-rate < 0.5% — норма, оставляем в наборе.
- 0.5–2% — добавляем в watchlist, разбираем при следующем релизе.
- > 2% — карантин: тест не блокирует пайплайн, но команда обязана починить за неделю.
- > 5% — удаляем из основного набора и заводим тикет уровня «техдолг блокирующий».
Эти числа считаются автоматически, не «по ощущениям». Иначе оценка зависит от того, кто последний разбирал прогон.
Где брать данные
Источник один: история запусков в CI. Каждый прогон — это запись «тест X на коммите Y дал результат Z, длительность D». На основе этой таблицы строится метрика.
На Playwright и pytest достаточно сохранять JUnit-отчёты в S3 после каждого прогона:
- name: Run e2e
run: npx playwright test --reporter=junit
env:
PLAYWRIGHT_JUNIT_OUTPUT_NAME: junit.xml
- name: Upload report
if: always()
run: |
aws s3 cp junit.xml \
s3://qa-reports/${{ github.run_id }}/junit.xml
Дальше — небольшой скрипт, который раз в сутки парсит отчёты за последние 30 дней и считает по каждому тесту:
from pathlib import Path
from xml.etree import ElementTree as ET
from collections import defaultdict
stats = defaultdict(lambda: {"runs": 0, "fails": 0, "retries": 0})
for report in Path("reports").glob("**/junit.xml"):
tree = ET.parse(report)
for case in tree.iter("testcase"):
name = f'{case.get("classname")}::{case.get("name")}'
stats[name]["runs"] += 1
if case.find("failure") is not None or case.find("error") is not None:
stats[name]["fails"] += 1
# retry-флаг ставит сам runner: rerunfailures, playwright retries
if case.get("retries", "0") != "0":
stats[name]["retries"] += 1
flaky = []
for name, s in stats.items():
if s["runs"] < 10:
continue
rate = s["retries"] / s["runs"]
if rate > 0.005:
flaky.append((name, rate, s))
flaky.sort(key=lambda x: -x[1])
for name, rate, s in flaky[:30]:
print(f"{rate * 100:5.2f}% {s['runs']:4d} {name}")
Этот скрипт раз в сутки кидает топ-30 в Slack. Команда видит свой собственный список «горячих» тестов, без него никто такие тесты не помнит.
Retry на разных уровнях
Retry — это бинт, не лекарство. Но без него на CI больно. Использую трёхуровневую стратегию.
Уровень 1: ретрай на уровне действия
Это то, что Playwright делает сам: locator.click() ждёт, пока элемент станет кликабелен, в пределах actionTimeout. expect().toBeVisible() ретраит ассерт. Это бесплатно, не оставляет следов в отчёте, нужно просто не выключать.
В pytest аналог — это явные ожидания готовности. Например, ожидание, что после POST на сервис джоба перешла в статус done:
import time
import httpx
def wait_until_done(client: httpx.Client, job_id: str, timeout: float = 30.0) -> dict:
deadline = time.monotonic() + timeout
last_status = None
while time.monotonic() < deadline:
response = client.get(f"/jobs/{job_id}")
body = response.json()
last_status = body["status"]
if last_status == "done":
return body
if last_status == "failed":
raise AssertionError(f"job failed: {body}")
time.sleep(0.5)
raise AssertionError(f"job not done in {timeout}s, last status: {last_status}")
Это не retry в привычном смысле, а правильное ожидание состояния. Но оно убирает половину flaky, потому что чаще всего «нестабильность» = «асинхронный сервис не успел».
Уровень 2: ретрай теста
В Playwright — retries: 2 в конфиге, на CI. В pytest — плагин pytest-rerunfailures:
pytest --reruns 2 --reruns-delay 1 -v
Главное правило: ретраю подвергаются только те тесты, которые помечены как нестабильные (через маркер или конфиг), а не весь набор. Иначе ретраи маскируют реальные регрессии в продукте.
import pytest
@pytest.mark.flaky(reruns=3, reruns_delay=2)
def test_payment_with_external_provider(client):
# внешний платёжный сервис, нестабильность не наша
...
Маркер на тесте — это документ. Он говорит: «здесь нестабильность зафиксирована, причина известна, починка вне нашего контроля». Без маркера ретраи запрещены.
Уровень 3: ретрай джобы
На уровне CI можно повторить весь шаг, если он упал. Это последняя линия обороны и у неё высокая цена: лишние минуты в пайплайне, расход CI-минут, скрытие реальных проблем. Использую только для интеграционных шагов с внешними зависимостями (поднять контейнер БД, скачать большой артефакт).
Карантин: как не накапливать боль
Если тест нестабилен и быстро починить нельзя, я переношу его в отдельный набор:
// playwright.config.ts
projects: [
{
name: 'main',
testIgnore: ['**/quarantine/**'],
},
{
name: 'quarantine',
testMatch: ['**/quarantine/**'],
retries: 3,
},
],
quarantine-проект гоняется отдельно, его падения не блокируют merge. Но раз в неделю разбираю каждый тест в карантине: чинится — возвращается, не чинится — заводится тикет на продукт или удаляется.
Если карантин не разбирать — он растёт и становится свалкой. Раз в спринт фиксированное время на разбор, иначе паттерн не работает.
Что попробовала и не зашло
- Глобальный
retries: 5на весь набор. Через месяц медианное время прогона выросло вдвое, никто не разбирает реальные проблемы, потому что «зелено же». - «Стабилизация» через увеличение таймаутов. Маскирует медленные места продукта. Мониторинг p95-времени теста показывает их явно — лучше чинить там.
- Запуск flaky-тестов «после основного прогона». Создаёт ощущение «у нас всё стабильно», потому что из репорта ушли красные. Реальная нестабильность не уменьшается.
- Локальный анализ статистики «глазами». Тестов много, забываешь. Через месяц теряешь ощущение, какой тест действительно горячий. Только автоматический отчёт.
Дашборд для команды
В Allure есть встроенный show-history, но мне он показался слишком грубым. Завожу простой Grafana-дашборд на основе той же базы JUnit-отчётов:
- Топ-20 тестов по flaky-rate за последние 30 дней.
- Тренд flaky-rate всего набора по неделям. Если растёт — пора собирать митинг.
- Тесты с самой большой дисперсией длительности — это индикатор медленных или нестабильных мест продукта.
- Список тестов, которые не прогонялись больше 7 дней — кандидаты на удаление как «мёртвый код в наборе».
Эти метрики команда видит в режиме реального времени и принимает по ним решения. Без них любая стратегия retry превращается в ритуал.
Чек-лист
- Сохраняй каждый JUnit-отчёт в долгосрочное хранилище.
- Считай flaky-rate автоматически и публикуй раз в сутки.
- Маркируй нестабильные тесты явно, не включай ретраи глобально.
- Заведи карантин-проект с собственным расписанием.
- Раз в спринт разбирай карантин, иначе он деградирует в свалку.
- Следи за дисперсией длительности — это индикатор будущих flaky.
- Жёсткое правило: если тест в карантине больше месяца — удаляем.
Без автоматики борьба с flaky сводится к «пострадал — починил — забыл». С автоматикой набор стабилизируется не за пару спринтов, а постоянно, и это видно по графикам, а не по самочувствию команды.