Отчёты и анализ падений
Красная сборка без отчёта — это записка «что-то сломалось». Отчёт превращает её в «на шаге „оплатить“ кнопка не появилась за 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) — только она превращает «кажется, он иногда падает» в цифру.