Скриншоты и отладка падений

Тест упал в CI в три часа ночи, браузера уже нет, повторить не получается. Единственное, что у вас останется, — артефакты. Научимся собирать их автоматически.

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

Зачем это нужно на практике

Разница между зрелым автотестом и «скриптом, который иногда зелёный» — именно здесь. Голый трейсбек TimeoutException: Message: (да, Selenium любит пустое сообщение) не говорит ни-че-го: элемента не было, потому что не загрузилась страница? Потому что бэкенд вернул 500? Потому что редиректнуло на логин? Потому что вылез куки-баннер и перекрыл кнопку? Все четыре гипотезы снимаются одним взглядом на скриншот. Практика простая и жёсткая: любое падение UI-теста обязано оставлять после себя картинку и HTML. Не «хорошо бы», а обязано — иначе команда начинает лечить симптомы перезапусками.

Скриншот: три уровня детализации

Самый частый вариант — снимок видимой части окна:

driver.save_screenshot("artifacts/login-fail.png")   # True/False

png_bytes = driver.get_screenshot_as_png()           # чтобы приложить в Allure/отчёт
b64 = driver.get_screenshot_as_base64()              # чтобы встроить прямо в HTML-отчёт

# скриншот одного элемента — когда важна деталь, а не вся страница
banner = driver.find_element(By.CSS_SELECTOR, ".error-banner")
banner.screenshot("artifacts/error-banner.png")

Важно понимать: по спецификации WebDriver команда Take Screenshot снимает вьюпорт, а не всю страницу целиком. Длинная форма ниже сгиба в кадр не попадёт. У Firefox для этого есть готовый метод driver.get_full_page_screenshot_as_file(...), в Chrome полностраничный снимок делается через CDP-команду Page.captureBeyondViewport. На практике проще другое: заранее задать в headless нормальный размер окна, чтобы страница помещалась.

options = webdriver.ChromeOptions()
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")   # без этого в headless часто 800x600
options.set_capability("goog:loggingPrefs", {"browser": "ALL"})   # логи консоли

Артефакты сами, без ручного вызова

Писать save_screenshot в каждом except — путь в никуда: забудете в половине тестов. Правильно — повесить сбор артефактов на инфраструктуру. В pytest это делается через хук, который сообщает фикстуре, упал тест или нет:

# conftest.py
import datetime
import pathlib
import pytest
from selenium import webdriver

ARTIFACTS = pathlib.Path("artifacts")


@pytest.hookimpl(hookwrapper=True, tryfirst=True)
def pytest_runtest_makereport(item, call):
    """Кладём результат каждой фазы теста на сам объект теста."""
    outcome = yield
    report = outcome.get_result()
    setattr(item, "rep_" + report.when, report)   # rep_setup / rep_call / rep_teardown


@pytest.fixture
def driver(request):
    options = webdriver.ChromeOptions()
    options.add_argument("--headless=new")
    options.add_argument("--window-size=1920,1080")
    options.set_capability("goog:loggingPrefs", {"browser": "ALL"})
    drv = webdriver.Chrome(options=options)

    yield drv   # здесь выполняется сам тест

    report = getattr(request.node, "rep_call", None)
    if report is not None and report.failed:
        dump_artifacts(drv, request.node.name)
    drv.quit()


def dump_artifacts(drv, test_name):
    ARTIFACTS.mkdir(parents=True, exist_ok=True)
    stamp = datetime.datetime.now().strftime("%H%M%S")
    base = ARTIFACTS / (test_name + "-" + stamp)

    drv.save_screenshot(str(base) + ".png")                       # что видел браузер
    (ARTIFACTS / (base.name + ".html")).write_text(
        drv.page_source, encoding="utf-8")                        # что было в DOM

    lines = ["URL: " + drv.current_url, "TITLE: " + drv.title, ""]
    for entry in drv.get_log("browser"):                          # ошибки JS и сети
        lines.append(entry["level"] + " " + entry["message"])
    (ARTIFACTS / (base.name + ".log")).write_text(
        "\n".join(lines), encoding="utf-8")

Что мы получили. Хук pytest_runtest_makereport срабатывает после каждой фазы теста и записывает отчёт прямо на объект теста; фикстура после yield смотрит на rep_call и, если тест упал, зовёт dump_artifacts. Драйвер закрывается после сбора — порядок критичен, из закрытого браузера скриншот уже не снять. И заметьте: сбор происходит один раз, в одном месте, и работает для всех тестов сразу — авторам тестов вообще не нужно об этом помнить.

Три файла на падение — это минимум, который окупается с первого же разбора:

АртефактОтвечает на вопрос
.png (скриншот)что видел пользователь: баннер, спиннер, пустая страница, чужой экран
.html (page_source)был ли элемент в DOM вообще; какой у него реальный класс и текст
.log (URL + консоль)куда нас занесло и не сыпал ли фронтенд ошибками JS/сети

Имя файла тоже имеет значение

В CI артефакты складываются в общую папку, и screenshot.png от двадцати тестов — это один перезаписанный файл. Имя должно быть уникальным и безопасным для файловой системы: параметризованные тесты приносят в node.name и слэши, и скобки, и почту. Вот маленькая чистилка на чистом Python — её можно запустить прямо здесь:

import re

def safe_name(test_id):
    name = re.sub(r"[^\w.-]+", "_", test_id)
    return name.strip("_")[:80]

print(safe_name("tests/test_login.py::test_login[user@mail.ru-Chrome 120]"))
print(safe_name("test_cart[3 товара / скидка 10%]"))

Результат:

tests_test_login.py_test_login_user_mail.ru-Chrome_120
test_cart_3_товара_скидка_10

Как быстро понять причину падения в CI

Открывайте артефакты в одном и том же порядке: сначала скриншот (он отвечает на 80% вопросов за две секунды), потом URL, потом HTML, и только в конце — трейсбек. Шпаргалка по типовым картинкам:

Что на скриншотеПочти наверняка причина
Белый пустой экранСтраница не загрузилась: стенд лежит, не тот URL, упал деплой
Форма логина вместо кабинетаСессия не применилась: протухла кука, тестовый пользователь удалён
Баннер про куки поверх кнопкиElementClickInterceptedException — закрывайте баннер в фикстуре
Крутится спиннерМало ждали или стенд медленнее локальной машины: ждите условие, не время
Вёрстка «съехала», элемент за краемОкно 800x600 в headless — задайте --window-size
Текст ошибки 500 от бэкендаЭто не флейк, это баг приложения — заводите дефект, а не перезапуск
Всё выглядит нормальноСмотрите HTML: элемент есть, но локатор не тот (сменился класс/id)

Отдельно про headless: он не идентичен обычному браузеру. Другой размер окна по умолчанию, другой User-Agent, иногда другой рендеринг шрифтов и отсутствие анимаций. Если тест зелёный локально и красный в CI — первым делом сравните скриншот из CI с тем, что видите вы. Половина «нестабильных» тестов лечится именно здесь.

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

Скриншот делает не Selenium, а сам браузер: драйвер отправляет команду Take Screenshot, браузер рисует текущий вьюпорт и возвращает PNG, закодированный в base64. Поэтому get_screenshot_as_base64() — самый «родной» метод, а save_screenshot() просто декодирует эту строку и пишет байты в файл. Отсюда же следует ограничение: сфотографировать можно только то, что реально отрисовано в момент вызова, — снимок «прошлого состояния» получить невозможно, и это ещё один довод в пользу автоматического сбора сразу при падении.

page_source отдаёт сериализованный текущий DOM, уже после работы JavaScript, — это не тот HTML, что пришёл по сети. Именно поэтому он и полезен: вы видите разметку такой, какой её видел ваш локатор. А get_log("browser") — не часть стандарта W3C, это расширение Chrome (и оно требует включённого goog:loggingPrefs); в Firefox его нет, и вызов бросит исключение. Оборачивайте сбор логов в try/except, чтобы падение сборщика артефактов не заслонило падение самого теста.

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

  • driver.quit() раньше сбора артефактов. Браузера уже нет — скриншот не снимется, а вы получите второе, бесполезное исключение поверх настоящего.
  • Скриншот пишется только в except внутри теста. Ассерты падают не в try, тесты множатся, кто-то забудет. Сбор — в фикстуре или хуке, один раз для всех.
  • Одно имя файла на все тесты — в CI останется скриншот последнего упавшего.
  • Артефакты не выкладываются из джобы. Красиво сохранили в контейнер, который тут же умер. Настройте выгрузку папки (actions/upload-artifact, artifacts: в GitLab CI).
  • Скриншот есть, HTML нет. Картинка не покажет, что у кнопки сменился data-testid, — а page_source покажет.
  • Падение объявляют флейком и включают ретрай. Ретраи маскируют настоящие баги. Сначала посмотрите артефакт, потом принимайте решение.

Итоги

  • Правило номер один: каждое падение UI-теста оставляет скриншот, HTML страницы и URL с логами консоли. Без этого разбор превращается в гадание.
  • save_screenshot снимает вьюпорт, а не всю страницу; в headless обязательно задавайте --window-size.
  • Сбор артефактов — задача инфраструктуры: хук pytest_runtest_makereport плюс фикстура, а не try/except в каждом тесте. Артефакты собираем до quit().
  • Читайте артефакты по порядку: скриншот → URL → HTML → трейсбек. Шпаргалка «что на картинке» экономит часы.
  • Разница между локальным браузером и headless в CI — источник половины «нестабильных» тестов; скриншот из CI покажет её мгновенно.
Проверьте себя
1. В фикстуре драйвера порядок такой: drv.quit(), затем drv.save_screenshot(...). Что получится при падении теста?
AСкриншот сохранится корректно — драйвер держит последний кадр в памяти
BСкриншот не сохранится: браузер уже закрыт, команда упадёт с исключением поверх настоящей ошибки
CСохранится скриншот пустой страницы about:blank
DSelenium автоматически переоткроет браузер и снимет экран
2. Тест зелёный локально, но в CI падает, и на скриншоте видно, что кнопка «Оформить заказ» ушла за нижний край экрана. Что проверить первым делом?
AЛокатор кнопки — вероятно, в CI другая вёрстка
BРазмер окна headless-браузера: по умолчанию он часто 800x600, нужен аргумент --window-size
CСкорость сети на агенте CI
DВерсию Selenium — она отличается от локальной