lenec ru

← все посты

Как ловить flaky-тесты автоматически: метрики и retry-стратегии

16K

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 превращается в ритуал.

Чек-лист

  1. Сохраняй каждый JUnit-отчёт в долгосрочное хранилище.
  2. Считай flaky-rate автоматически и публикуй раз в сутки.
  3. Маркируй нестабильные тесты явно, не включай ретраи глобально.
  4. Заведи карантин-проект с собственным расписанием.
  5. Раз в спринт разбирай карантин, иначе он деградирует в свалку.
  6. Следи за дисперсией длительности — это индикатор будущих flaky.
  7. Жёсткое правило: если тест в карантине больше месяца — удаляем.

Без автоматики борьба с flaky сводится к «пострадал — починил — забыл». С автоматикой набор стабилизируется не за пару спринтов, а постоянно, и это видно по графикам, а не по самочувствию команды.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.