Отчёты и анализ падений

Красная сборка без отчёта — это записка «что-то сломалось». Отчёт превращает её в «на шаге „оплатить“ кнопка не появилась за 10 секунд, вот скриншот и вот запрос, который вернул 500».

Отчёт о прогоне — не украшение для менеджера, а инструмент диагностики: он должен отвечать на вопрос «что именно сломалось и по чьей вине» без запуска тестов заново.

Зачем это нужно

Стандартный вывод pytest в консоли CI выглядит так: сотни строк, где-то посередине трейсбек и TimeoutException. Человек, который смотрит на это в семь вечера пятницы, не понимает главного — это баг продукта, упавший стенд или просто нестабильный тест. И чаще всего решение принимается самое простое: «перезапущу-ка я сборку». Так рождается культура, в которой красным тестам никто не верит.

Хороший отчёт закрывает три вопроса: что делал тест (шаги), что он видел в момент падения (скриншот, HTML страницы, логи браузера), падал ли он раньше (история). Ответив на них, вы за минуту отличаете реальный дефект от шума.

Подключаем Allure

Allure — генератор отчётов: pytest-плагин складывает по ходу прогона JSON-файлы, а консольная утилита превращает их в статический сайт.

pip install allure-pytest

# прогон: сырые результаты падают в папку
pytest --alluredir=allure-results

# посмотреть локально (поднимет временный веб-сервер)
allure serve allure-results

# собрать статический отчёт для CI
allure generate allure-results --clean -o allure-report

Обратите внимание: allure-results — это не отчёт, а сырьё. Отчёт получается из него отдельной командой. Путаница между этими двумя папками — причина половины вопросов «а почему у меня пустая страница».

Шаги и вложения

Без разметки Allure покажет ровно то же, что и pytest: имя теста и трейсбек. Ценность появляется, когда тест сам рассказывает о себе.

import allure
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC


@allure.step("Войти под пользователем {email}")
def login(driver, email, password):
    driver.find_element(By.ID, "email").send_keys(email)
    driver.find_element(By.ID, "password").send_keys(password)
    driver.find_element(By.CSS_SELECTOR, "[data-test=submit]").click()


@allure.step("Добавить товар {sku} в корзину")
def add_to_cart(driver, sku):
    driver.find_element(By.CSS_SELECTOR, f"[data-sku='{sku}']").click()
    WebDriverWait(driver, 10).until(
        EC.text_to_be_present_in_element((By.CSS_SELECTOR, "[data-test=cart-count]"), "1")
    )


@allure.title("Товар добавляется в корзину")
@allure.severity(allure.severity_level.CRITICAL)
def test_add_to_cart(driver, user):
    with allure.step("Открыть каталог"):
        driver.get("https://staging.example.com/catalog")

    login(driver, user.email, user.password)
    add_to_cart(driver, sku="BOOK-1")

    with allure.step("Проверить счётчик корзины"):
        assert driver.find_element(By.CSS_SELECTOR, "[data-test=cart-count]").text == "1"

Что даёт эта разметка:

  • @allure.step на функции — каждый вызов превращается в строку в отчёте, причём с подставленными аргументами: «Войти под пользователем qa-8f21@example.com». В отчёте сразу видно, на каком шаге всё оборвалось.
  • with allure.step("...") — то же самое для куска кода внутри теста.
  • @allure.severity — приоритет. Когда упало 30 тестов, разбор начинают с critical, а не с первого попавшегося.

Второй столп отчёта — вложения. Их надо снимать автоматически у каждого упавшего теста, иначе про них забудут. Делается это хуком в conftest.py:

import allure
import pytest


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
    outcome = yield
    report = outcome.get_result()

    if report.when == "call" and report.failed:
        driver = item.funcargs.get("driver")
        if driver is None:
            return

        allure.attach(
            driver.get_screenshot_as_png(),
            name="screenshot",
            attachment_type=allure.attachment_type.PNG,
        )
        allure.attach(
            driver.page_source,
            name="page-source",
            attachment_type=allure.attachment_type.HTML,
        )
        allure.attach(
            driver.current_url,
            name="url",
            attachment_type=allure.attachment_type.TEXT,
        )

Разбор построчно: pytest_runtest_makereport вызывается на каждой фазе теста, поэтому мы фильтруем report.when == "call" — то есть само тело теста, а не setup или teardown. item.funcargs даёт доступ к фикстурам этого теста, среди них наш driver. Важно, что хук отрабатывает до teardown-части фикстуры, то есть браузер ещё жив и скриншот снять можно. Порядок здесь критичен: сделаете quit() раньше — снимать будет нечего.

Failed или broken: почему это не одно и то же

Allure различает четыре статуса, и разница между двумя «красными» — самое полезное, что он вообще делает.

СтатусКогда возникаетО чём говорит
passedтест прошёл
failedне выполнился assertтест дошёл до проверки, но продукт повёл себя не так. Скорее всего — баг
brokenвылетело исключение, не связанное с assert: NoSuchElementException, TimeoutException, WebDriverExceptionтест не дошёл до проверки. Скорее всего — сломан сам тест или стенд
skippedтест пропущенпомечен skip или не выполнено условие

Практический смысл такой. Массовый failed — «кнопка не переименовалась» — почти всегда честный сигнал: код изменил поведение. Массовый broken — «элемент не найден» — почти всегда сигнал про нас самих: переехал локатор, не поднялся стенд, отвалилась авторизация. Команды, которые разделяют эти два ведра, разбирают падения в разы быстрее, потому что broken идёт к автоматизаторам, а failed — к разработчикам.

Отсюда и практический приём: не превращайте проверки продукта в исключения. Строка driver.find_element(By.ID, "success-banner") без ожидания даст broken, хотя вы проверяли поведение продукта. Правильнее дождаться явно и проверить assert — тогда падение будет failed и попадёт в правильное ведро.

Флаки-тесты и почему retry — это лечение симптома

Флаки-тест (flaky) — тест, который на одном и том же коде даёт то зелёный, то красный результат.

Соблазн очевиден: поставить перезапуск и забыть.

pip install pytest-rerunfailures
pytest --reruns 2 --reruns-delay 1

Сборка позеленела, все довольны. Но давайте честно: что вы сделали? Вы спрятали нестабильность, а не убрали её. И вот что вы за это заплатите.

  • Флаки-тест не отличает свою нестабильность от плавающего бага в продукте. Гонка в JavaScript, из-за которой кнопка иногда не навешивает обработчик, воспроизводится у одного пользователя из ста — и ваш retry только что заботливо это скрыл.
  • Прогон становится дольше: каждый упавший тест бежит трижды.
  • Доверие к тестам падает до нуля. Как только команда усваивает, что «красное — это просто надо перезапустить», настоящие баги начинают проезжать в прод.

Retry допустим как временный карантин: пометили нестабильный тест, завели на него задачу, перезапуск дал время разобраться. Недопустим как политика по умолчанию для всего набора.

Настоящие причины и настоящее лечение

Причина флакиКак чинить
Ожидание через time.sleep()Явные ожидания: WebDriverWait плюс expected_conditions. Ждём условие, а не секунды
Клик по элементу во время анимацииЖдать element_to_be_clickable, а не presence_of_element_located: «есть в DOM» не равно «кликабелен»
Общие тестовые данныеУникальный пользователь и уникальные сущности на каждый тест
Зависимость от порядкаКаждый тест сам готовит своё состояние и убирает за собой
Плавающие внешние сервисыЗаглушки/моки для всего, что не является предметом проверки
Зависимость от времени и часового поясаФиксировать время в стенде, не завязываться на «сегодня»
Нехватка CPU при параллелиУменьшить число воркеров до разумного, увеличить shm_size

Чтобы работать с флаки системно, нужен факт, а не ощущение. Его даёт история Allure: перед генерацией нового отчёта скопируйте папку history из предыдущего — и на карточке теста появится полоска последних прогонов, а в отчёте — вкладка Flaky.

cp -r allure-report/history allure-results/history
allure generate allure-results --clean -o allure-report

Дальше всё просто: тест, который за 20 прогонов дал 5 падений без единого изменения кода, — не «иногда падает», а сломан. Заводите на него баг ровно так же, как на дефект продукта.

Как это работает

Механика Allure нарочито примитивна, и это её сила. Плагин allure-pytest подписывается на события pytest и на каждый тест пишет в allure-results JSON-файл: имя, статус, длительность, дерево шагов, ссылки на вложения. Вложения — отдельные файлы рядом. Никакой базы, никакого сервера: просто папка.

Команда allure generate читает эту папку и собирает статический HTML-сайт. Поэтому отчёт можно положить артефактом в CI, залить на GitHub Pages или в S3 — он никуда не ходит и работает из файловой системы.

История же существует ровно потому, что вы сами переносите папку history из старого отчёта в новые результаты. Забыли перенести — «памяти» у отчёта нет, и вкладка Flaky будет пустой. Это не баг, это дизайн: Allure намеренно не хранит состояние.

Частые ошибки

  • Скриншот снимается после driver.quit(). Браузера уже нет — получите исключение вместо картинки. Снимайте в pytest_runtest_makereport, он отрабатывает раньше teardown.
  • Отчёт не выгружается у красной сборки. Шаг публикации артефактов должен быть с if: always(), иначе отчёт есть только там, где он не нужен.
  • Шаги названы «step 1», «step 2». Такой отчёт бесполезен. Название шага должно читаться как предложение из тест-кейса.
  • --reruns прописан в pytest.ini и стоит там второй год. Это не отчёт о качестве, это ковёр, под который заметена нестабильность.
  • Не разделяют failed и broken. Тогда автоматизаторы разбирают баги продукта, а разработчики — чужие протухшие локаторы. Все злятся.
  • Не чистят allure-results между прогонами. В отчёт попадают результаты прошлого запуска, и вы час ищете тест, который на самом деле давно починен.

Итоги

  • Allure = allure-results (сырые JSON) плюс allure generate (статический сайт). Не путайте папки.
  • Размечайте тесты @allure.step и allure.step(...) — отчёт должен показывать, на каком шаге всё оборвалось.
  • Скриншот, HTML страницы и URL прикрепляйте автоматически хуком pytest_runtest_makereport, пока браузер ещё жив.
  • failed — не сошёлся assert, скорее всего баг продукта. broken — вылетело исключение, скорее всего сломан тест или стенд.
  • Флаки-тест — такой же дефект, как баг в коде. Retry (--reruns) — обезболивающее, а не лечение: он одинаково хорошо прячет и нестабильность теста, и плавающий баг продукта.
  • Ведите историю (папка history) — только она превращает «кажется, он иногда падает» в цифру.
Проверьте себя
1. Тест упал с NoSuchElementException. Какой статус покажет Allure и о чём это обычно говорит?
Afailed — не сошёлся assert, значит в продукте баг
Bbroken — вылетело исключение, тест не дошёл до проверки: скорее всего протух локатор или не поднялся стенд
Cskipped — тест был пропущен из-за отсутствия элемента
Dunknown — Allure не умеет обрабатывать исключения Selenium
2. Тест падает примерно в одном прогоне из пяти. Команда добавила pytest --reruns 2, сборка позеленела. В чём главная опасность такого решения?
AОтчёт Allure перестанет собираться
BПерезапуски сильно нагружают Selenium Grid и он падает
CПерезапуск одинаково хорошо скрывает и нестабильность теста, и настоящий плавающий баг продукта — сигнал о дефекте потерян
Dpytest-rerunfailures несовместим с pytest-xdist