Скриншоты и отладка падений
Тест упал в 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 покажет её мгновенно.