Пишем безопасные автотесты для веба: Page Object, стабильные селекторы и параллельный запуск
Покажем, как строить надёжную тестовую архитектуру для UI: Page Object, стратегии поиска элементов без флейков, работа с ожиданиями и запуск тестов параллельно в CI. Пройдём путь от первого “зелёного” теста до устойчивого набора.
Содержание
Пишем безопасные автотесты для веба: Page Object, стабильные селекторы и параллельный запуск
Автотесты для веба редко ломаются «из-за кода теста». Чаще они ломаются из‑за хрупкости вокруг UI: селекторы начинают совпадать не там, ожидания оказываются недостаточными (или наоборот — избыточными), а параллельный запуск в CI вскрывает скрытые зависимости между тестами. В итоге команда тратит больше времени на «чинить зелёные» и обновлять селекторы, чем на проверку регрессий.
В этой статье разберём путь от первого «зелёного» теста до набора, который переживает изменения фронтенда, работает стабильно и может исполняться параллельно в CI. Я буду говорить в первую очередь о подходах и архитектуре, но местами покажу конкретный код: Page Object, стратегии поиска элементов без флейков, ожидания и параллелизацию.
Почему UI-тесты флейчат: реальные причины
Прежде чем проектировать архитектуру, важно понять источник нестабильности. Самые частые причины флейков в вебе:
-
Хрупкие селекторы
Выбрали CSS по классу, который поменялся, или по структуре DOM, которая «логически не должна меняться», но меняется вместе с версткой. -
Гонки (race conditions)
Тест ищет элемент до того, как он появился, или кликает по оверлею/анимации. В результате иногда действие проходит, иногда — нет. -
Смешение состояния между тестами
Тесты используют один и тот же пользователь/сессию/корзину/данные. При параллельном запуске эффект усиливается. -
Неправильные ожидания
Вместоwait until visible— фиксированныйsleep. Или наоборот: ожидание «любого» состояния вместо «конкретного результата». -
Сеть/фоновые запросы
UI реагирует на запросы с задержкой, а тест ждёт только DOM-изменение без учета реальной готовности данных. -
Непредсказуемая среда
Разный размер экрана, разные часовые пояса, разная версия браузера/драйвера, нестабильный DNS/загрузка ассетов.
Хорошая архитектура тестов минимизирует эти источники на уровне:
- селекторов (стабильность),
- ожиданий (детерминизм),
- изоляции тестов (отсутствие гонок),
- запуска (параллельность без конфликта ресурсов).
Архитектура UI: Page Object как каркас
Что такое Page Object (и чем он полезен)
Page Object — это паттерн, где представление страницы (или её части) оформляется как объект с методами действий и проверок. Идея: логика поиска и взаимодействия с элементами живёт рядом с «страницей», а тесты — становятся читаемыми сценариями, а не набором find_element(...).
Ключевой момент: Page Object — это не “ещё один файл с селекторами”, а механизм:
- централизовать стратегии поиска,
- закрепить соглашения (например, использовать
data-testid), - спрятать детали ожиданий,
- упростить рефакторинг при изменениях UI.
Пример структуры проекта
Один из рабочих вариантов:
tests/e2e/flows/— высокоуровневые сценарии (минимум селекторов)pages/— Page Objectsfixtures/— фикстуры окружения (пользователь, тестовые данные)
ui/selectors/— иногда можно выделить общие селекторыhelpers/— общие функции ожидания и действий
config/— параметры CI, переменные окружения
Минимальный Page Object на практике (Python + Playwright)
Ниже пример для Playwright (он удобен ожиданиями и автоожиданием). Если вы используете Selenium — принципы те же, но ожидания придётся писать более явно.
# tests/e2e/pages/login_page.py
from playwright.sync_api import Page, expect
class LoginPage:
def __init__(self, page: Page, base_url: str):
self.page = page
self.base_url = base_url
# Селекторы лучше хранить как константы внутри page object
self._email = page.locator("[data-testid='login-email']")
self._password = page.locator("[data-testid='login-password']")
self._submit = page.locator("[data-testid='login-submit']")
self._error = page.locator("[data-testid='login-error']")
def open(self):
self.page.goto(f"{self.base_url}/login")
def login(self, email: str, password: str):
self._email.fill(email)
self._password.fill(password)
self._submit.click()
def assert_error_message(self, expected: str):
# Ожидаем видимость конкретного сообщения
expect(self._error).to_be_visible()
expect(self._error).to_have_text(expected)
Обратите внимание на два аспекта:
data-testid: это “контракт” между тестами и UI, а не попытка угадывать классы.- Ожидания в Page Object: тесты меньше знают о деталях DOM и о том, “как именно” ждать.
Стабильные селекторы: стратегия вместо «угадывания»
Почему data-testid — де-факто стандарт
В идеале фронтенд-команда добавляет атрибуты тестовой идентификации (или вы договариваетесь о них). Тогда селекторы становятся:
- стабильными при изменениях верстки,
- независимыми от классов,
- менее чувствительными к перестройке DOM.
Минимальный набор:
data-testid="..."на элементах, которые нужно находить,- иногда
data-test-id/data-qa— зависит от команды, но важно единообразие.
Что делать, если data-testid нет
Если проект уже живёт без этих атрибутов, важно не впадать в крайность “всё переделать сейчас”. Практика показывает: лучше внедрять тестовые идентификаторы точечно — на узлах, которые чаще всего ломают UI-тесты.
Как точечно улучшать селекторы:
- Не используйте селекторы вида
.btn.primary.largeдля ключевых действий. - Не используйте
nth-childили жесткие цепочкиdiv > form > ..., если структура может меняться. - Используйте селекторы по роли/тексту, если UI семантичен:
get_by_role("button", name="Sign in")(Playwright) — часто устойчивее. - Проверяйте уникальность селектора: тест должен быть уверен, что на странице один элемент.
Избегаем типичных флейков селекторов
-
Селектор совпадает больше чем с одним элементом
Тогда действие может пройти по “не тому”, что нашлось первым. -
Элемент есть, но не интерактивен
Например, скрыт модальным окном или перекрыт. Решение: ожидатьvisibleи/илиenabled. -
Селектор по тексту слишком общий
“Submit” встречается в нескольких местах. Лучше селекторы с контекстом (Page Object) или с уточнением по родителю.
Практика: ограничения и контекст локатора
Даже если селектор стабильный, важно держать его в рамках контекста компонента или страницы.
# tests/e2e/pages/cart_page.py
from playwright.sync_api import Page, expect
class CartPage:
def __init__(self, page: Page):
self.page = page
self._items = page.locator("[data-testid='cart-items']")
self._empty_state = page.locator("[data-testid='cart-empty']")
self._checkout_button = page.locator("[data-testid='cart-checkout']")
def assert_empty(self):
expect(self._empty_state).to_be_visible()
expect(self._items).to_have_count(0)
def checkout(self):
expect(self._checkout_button).to_be_enabled()
self._checkout_button.click()
Ожидания: детерминизм вместо “sleep”
Автоожидание и смысл “правильных ожиданий”
Проблема фиксированных задержек (sleep) в том, что вы:
- либо ждёте слишком мало,
- либо ждёте слишком долго,
- и главное — “время” не равно “готовности”.
Правильные ожидания — это ожидания состояния, например:
- элемент стал видимым,
- кнопка стала доступной,
- появился текст,
- исчезло модальное окно,
- загрузчик пропал,
- сетевой запрос завершился (в продвинутых случаях).
Пример: ожидание результата после клика
# tests/e2e/flows/test_login_failure.py
from playwright.sync_api import Page
from tests.e2e.pages.login_page import LoginPage
def test_login_shows_error(page: Page, base_url: str):
login = LoginPage(page, base_url)
login.open()
login.login("wrong@example.com", "wrong-password")
login.assert_error_message("Неверный логин или пароль")
Здесь ожидание внутри assert_error_message гарантирует, что тест не “угадывает”, а проверяет реальное состояние.
Когда нужно ожидание сетевых запросов
Иногда UI меняется быстро, но данные ещё не загрузились. Тогда вы тестируете “не тот момент”. Для Playwright можно ждать конкретный запрос или ответ — это снижает флейки в интеграционных сценариях.
Пример (идея, которую можно адаптировать):
# Пример: ожидание ответа поиска
def test_search_results_are_loaded(page, base_url):
page.goto(f"{base_url}/catalog")
# Ждём конкретный API-запрос
with page.expect_response(lambda resp: "/api/search" in resp.url and resp.status == 200):
page.get_by_test_id("search-input").fill("laptop")
page.get_by_test_id("search-submit").click()
# Затем проверяем UI-результат
page.get_by_test_id("search-results").locator("[data-testid='product-card']").first.wait_for()
На практике это стоит применять точечно: там, где DOM-изменение не гарантирует готовность данных.
Границы ожиданий: что нельзя делать
- Не делайте ожидание “любой элемент видим” — это маскирует проблемы.
- Не проверяйте “текст появился где-то” без привязки к контейнеру.
- Не используйте глобальные селекторы из тестов — переносите в Page Object.
Изоляция тестов: чтобы параллельность не ломала данные
Почему параллельный запуск выявляет скрытые зависимости
Если тесты последовательно не мешают друг другу, это не значит, что они независимы. Параллельность “раскроет” общие сущности:
- один и тот же пользователь,
- одна и та же корзина,
- общий набор тестовых данных,
- одинаковые временные идентификаторы,
- общий файл/папка/ключ в окружении.
Модели изоляции: три подхода
-
Пер-тестовый аккаунт / пер-тестовая сессия
Создаём пользователя на каждый тест (или на каждый worker). Дороже, зато надёжнее. -
Одинаковая фикстура данных, но уникальные “ключи” действий
Например, товары или сущности создаются с уникальными названиями поuuid. -
Транзакционный подход / сброс состояния
После теста откатываем изменения или чистим таблицы.
Выбор зависит от вашей инфраструктуры. В CI проще всего внедрить уникальные ключи и чистку по окончании теста, а аккаунты — автоматизировать через API.
Пример: уникальные данные для параллельных тестов
import uuid
def unique_email():
return f"test_{uuid.uuid4().hex}@example.com"
Дальше:
- создаём пользователя с таким email,
- используем email/идентификатор в проверках,
- после теста удаляем пользователя через API (если возможно).
Параллельный запуск в CI: практические детали
Что важно на уровне конфигурации
У параллельного запуска есть минимум три задачи:
- Распределить тесты по воркерам
- Изолировать окружение
- Стабильно поднять браузеры и артефакты
Если вы используете Playwright, он уже поддерживает параллельность — но вы должны обеспечить корректное окружение.
Пример команды запуска
Допустим, у вас Playwright:
- параллельно по файлам (
workers) - с сохранением артефактов при падении.
Вариант CLI (идея):
pytest -q --maxfail=1
# или если запуск идет напрямую Playwright:
# npx playwright test --workers=4
Если тесты на Python через pytest, параллельность обычно делают через pytest-xdist:
pytest -n auto
Конфликт ресурсов: что обычно ломается первым
-
Файлы и директории артефактов
Важно, чтобы скриншоты/логи сохранялись с уникальными путями (часто это автоматически, но проверьте). -
Глобальные куки/профили браузера
Если вы используете один профиль на весь прогон — появятся конфликты. Лучше каждый worker создаёт свой context. -
Экономия на “дорогом” setup
Общий “seed” в начале может вступать в гонку. Setup должен быть либо:- до параллельности (и тогда неизменным),
- либо per-worker/per-test.
Нормализация окружения в CI
Стабильность выше, когда вы фиксируете:
- версию браузера,
- размер viewport (если UI респонсивный),
- язык/часовой пояс (в зависимости от приложения),
- переменные окружения.
В идеале тесты должны быть детерминированы не только логически, но и “по среде”.
От первого зелёного теста до устойчивого набора: путь развития
Шаг 1. Сделайте тест “правильным”, а не просто проходящим
Даже один тест можно привести к устойчивости:
- перенесите селекторы в Page Object,
- добавьте ожидания результата,
- исключите
sleepтам, где можно ожидать состояние.
Критерий: тест не должен падать при умеренных задержках сети.
Шаг 2. Введите соглашение по селекторам
Определите правило:
- ключевые элементы имеют
data-testid, - в тестах нет “сырых” селекторов,
- допускается выбор селектора по роли/тексту только если UI семантически устойчив.
Шаг 3. Сделайте тесты независимыми по данным
Как только появляется второй тест, вы рискуете начать “влиять друг на друга”. Начните с простого:
- уникальные идентификаторы,
- чистка данных после теста,
- отсутствие общих глобальных переменных.
Шаг 4. Подключите параллельный запуск и посмотрите на ошибки
Параллельный запуск — это не финальная настройка. Его лучше включать рано, хотя бы с небольшим числом воркеров (например, 2–4), чтобы быстро обнаружить:
- конфликт пользователей,
- конфликт ресурсов,
- отсутствие ожиданий по сети.
Шаг 5. Приведите регресс к воспроизводимости
Когда тест падает, он должен оставлять подсказки:
- trace/video (если используете),
- скриншот,
- текст/состояние,
- логи страницы.
Это ускоряет исправления в разы. И именно это часто отличает набор “для галочки” от реального инструмента.
Пример: связка Page Object + стабильные ожидания + сценарий теста
Соберём короткий пример: логин, затем проверка состояния главной страницы.
# tests/e2e/pages/home_page.py
from playwright.sync_api import Page, expect
class HomePage:
def __init__(self, page: Page):
self.page = page
self._welcome = page.locator("[data-testid='welcome-message']")
self._logout = page.locator("[data-testid='logout-button']")
def assert_logged_in(self, email: str):
expect(self._welcome).to_be_visible()
expect(self._welcome).to_contain_text(email)
def logout(self):
self._logout.click()
# tests/e2e/flows/test_login_success.py
from playwright.sync_api import Page
from tests.e2e.pages.login_page import LoginPage
from tests.e2e.pages.home_page import HomePage
import uuid
def test_login_success(page: Page, base_url: str, api_client):
# api_client создаёт пользователя через API; email уникальный
email = f"u_{uuid.uuid4().hex}@example.com"
password = "StrongPassword!123"
api_client.create_user(email=email, password=password)
login = LoginPage(page, base_url)
login.open()
login.login(email, password)
home = HomePage(page)
home.assert_logged_in(email)
home.logout()
Здесь важны детали:
- создание пользователя через API, чтобы UI-тест был сфокусирован на UI логине;
- уникальный email для параллельности;
- ожидания внутри страниц.
Типичные ошибки, которые “незаметно” портят архитектуру
-
Смешивание ответственности
Когда тесты содержат логику ожиданий, а Page Object не содержит ничего, кроме констант — вы получаете “полутесты”, которые сложно поддерживать. -
Тесты “знают” DOM
Чем больше селекторов
Комментарии
Пока нет комментариев