Page Object Model

Локаторы, размазанные по тестам, — главная причина, по которой автотесты через полгода тихо отключают в CI. Page Object лечит это одним архитектурным движением.

Page Object Model (POM) — паттерн, при котором каждая страница приложения описывается отдельным классом: локаторы (селекторы элементов) спрятаны внутри, а наружу торчат методы на языке пользователя — login_as(), add_to_cart(), open_filters().

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

Представьте: в проекте 60 UI-тестов, и почти каждый начинается со входа в систему. Фронтендер переименовал кнопку из button.btn-primary в button.button--main. Если селектор написан прямо в тестах — вы правите его в 60 местах, причём про два из них обязательно забудете, и они будут падать «загадочно». Это не гипотетический ужастик, а типовой понедельник любой команды, которая писала тесты «как получится».

Причина боли в том, что тест смешивает два совершенно разных уровня знания. Первый — что мы проверяем: пользователь входит под своей почтой и видит своё имя. Второй — как технически кликнуть: найди элемент с id email, введи текст, подожди появления блока с классом user-menu. Первый уровень меняется редко (бизнес-сценарий живёт годами), второй — на каждом релизе фронтенда. Смешивать их в одном файле — это как хранить бизнес-логику прямо в SQL-запросах: работает, пока проект маленький.

Тест, который скоро сломается

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


def test_uspeshnyy_vhod():
    driver = webdriver.Chrome()
    driver.get('https://demo.codechick.io/login')

    driver.find_element(By.CSS_SELECTOR, '#email').send_keys('anna@example.com')
    driver.find_element(By.CSS_SELECTOR, '#password').send_keys('S3cret-pass')
    driver.find_element(By.CSS_SELECTOR, 'button.btn-primary').click()

    WebDriverWait(driver, 10).until(
        EC.visibility_of_element_located((By.CSS_SELECTOR, '.user-menu'))
    )
    menu = driver.find_element(By.CSS_SELECTOR, '.user-menu')
    assert 'Анна' in menu.text

    driver.quit()

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

  • Дублирование локаторов. Тех же трёх строк входа в системе будет пятьдесят копий.
  • Нечитаемость. Чтобы понять, что проверяет тест, приходится мысленно исполнять CSS-селекторы. Менеджер такой тест не прочитает никогда.
  • Дублирование ожиданий. WebDriverWait с одними и теми же аргументами будет скопирован повсюду, и рано или поздно кто-то поставит таймаут 3 секунды «чтобы быстрее», породив плавающие падения.
  • driver.quit() внутри теста. Если assert упадёт, до quit() исполнение не дойдёт — браузер останется висеть в памяти агента CI. Сто таких падений, и агент умирает по OOM.

Страница как класс

Идея POM: на каждую значимую страницу (или крупный самостоятельный компонент вроде шапки или модального окна корзины) заводим класс. Внутри класса — константы-локаторы и методы-действия. Тест не знает ни одного селектора: он общается со страницей так же, как живой пользователь общается с интерфейсом.

Базовый класс: общая механика ожиданий

# pages/base_page.py
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC


class BasePage:
    path = '/'

    def __init__(self, driver, base_url='https://demo.codechick.io', timeout=10):
        self.driver = driver
        self.base_url = base_url
        self.wait = WebDriverWait(driver, timeout)

    def open(self):
        self.driver.get(self.base_url + self.path)
        return self

    def find(self, locator):
        return self.wait.until(EC.visibility_of_element_located(locator))

    def click(self, locator):
        self.wait.until(EC.element_to_be_clickable(locator)).click()

    def fill(self, locator, text):
        field = self.find(locator)
        field.clear()
        field.send_keys(text)

Обратите внимание: ожидание живёт ровно в одном месте. Каждый find и click автоматически ждёт элемент — значит, ни один автор теста уже не сможет «забыть подождать» и получить плавающий ElementNotInteractableException.

Страница логина: локаторы плюс действия

# pages/login_page.py
from selenium.webdriver.common.by import By

from pages.base_page import BasePage
from pages.dashboard_page import DashboardPage


class LoginPage(BasePage):
    path = '/login'

    EMAIL = (By.CSS_SELECTOR, '[data-test=login-email]')
    PASSWORD = (By.CSS_SELECTOR, '[data-test=login-password]')
    SUBMIT = (By.CSS_SELECTOR, '[data-test=login-submit]')
    ERROR = (By.CSS_SELECTOR, '[data-test=login-error]')

    def _enter_credentials(self, email, password):
        self.fill(self.EMAIL, email)
        self.fill(self.PASSWORD, password)
        self.click(self.SUBMIT)

    def login_as(self, email, password):
        """Успешный вход: возвращаем страницу, на которую попал пользователь."""
        self._enter_credentials(email, password)
        return DashboardPage(self.driver, self.base_url)

    def login_expecting_error(self, email, password):
        """Неуспешный вход: остаёмся на этой же странице."""
        self._enter_credentials(email, password)
        return self

    @property
    def error_text(self):
        return self.find(self.ERROR).text

Страница после входа

# pages/dashboard_page.py
from selenium.webdriver.common.by import By

from pages.base_page import BasePage


class DashboardPage(BasePage):
    path = '/dashboard'

    USER_MENU = (By.CSS_SELECTOR, '[data-test=user-menu]')

    @property
    def current_user(self):
        return self.find(self.USER_MENU).text

Тест после рефакторинга

# tests/test_login.py
from pages.login_page import LoginPage


def test_uspeshnyy_vhod(driver):
    dashboard = LoginPage(driver).open().login_as('anna@example.com', 'S3cret-pass')
    assert 'Анна' in dashboard.current_user


def test_vhod_s_nevernym_parolem(driver):
    page = LoginPage(driver).open().login_expecting_error('anna@example.com', 'nope')
    assert page.error_text == 'Неверная почта или пароль'

Две строки на тест. Их читает и понимает человек, который вообще не знает Selenium. А главное — когда фронтендер переименует кнопку, вы поправите одну константу в LoginPage, и все шестьдесят тестов снова зелёные. Ради этого момента паттерн и придуман.

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

Локатор — это кортеж. (By.CSS_SELECTOR, '[data-test=login-submit]') — обычный tuple из двух элементов. Он подходит и для expected_conditions (те принимают именно кортеж), и для driver.find_element(*locator) — звёздочка распаковывает кортеж в два аргумента. Поэтому локаторы удобно хранить константами класса.

Элементы ищутся лениво. В __init__ мы не вызываем find_element — только сохраняем драйвер. Это принципиально: если найти WebElement заранее и сохранить в поле, то после любой перерисовки DOM (React обновил список, страница перезагрузилась) ссылка протухнет и Selenium бросит StaleElementReferenceException. Поиск строго в момент действия — единственный безопасный вариант.

Метод возвращает страницу. login_as() возвращает DashboardPage, потому что после успешного входа пользователь физически оказывается на другой странице. Такой возврат превращает Page Object в карту переходов приложения и даёт автодополнение в IDE: написали точку — увидели, что можно делать дальше.

Внутри Page Object нет ни одного assert. Страница отвечает на вопрос «что видно и что можно нажать», а решение «правильно это или нет» принимает тест. Смешаете — и не сможете переиспользовать страницу в сценарии, где ожидается другой результат.

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

ОшибкаЧем оборачивается
Хрупкие локаторы: XPath вида /html/body/div[3]/div[2]/form/buttonЛомаются от любой вставки div. Просите у фронтендеров атрибут data-test — это самый дешёвый вклад в стабильность тестов.
assert внутри Page ObjectСтраницу нельзя переиспользовать для негативных сценариев, а сообщение о падении приходит не оттуда, где ошибка.
Один гигантский MainPage на весь сайтКласс на 900 строк, в котором никто ничего не находит. Дробите: шапка, корзина, модалка — отдельные объекты.
time.sleep(3) внутри методов страницыТесты замедляются в разы и всё равно падают на медленном CI. Ожидание должно быть явным и условным (WebDriverWait).
Кэширование WebElement в атрибуте классаStaleElementReferenceException после первой же перерисовки.

Итоги

  • Page Object разделяет два уровня знания: тест знает что делает пользователь, страница знает где кликать.
  • Локатор меняется в одном месте — вся сюита остаётся зелёной.
  • BasePage централизует ожидания: забыть подождать становится физически невозможно.
  • Методы возвращают Page Object — получаем читаемые цепочки и карту переходов.
  • Проверки (assert) живут только в тестах, никогда — в страницах.
Проверьте себя
1. Почему в Page Object не стоит сохранять найденный WebElement в поле объекта при создании страницы?
AЭто замедляет запуск браузера
BПосле перерисовки DOM ссылка протухает и Selenium бросает StaleElementReferenceException
CSelenium запрещает хранить элементы в атрибутах класса
DТак теряется подсветка синтаксиса в IDE
2. Что из перечисленного НЕ должно находиться внутри класса-страницы (Page Object)?
AКонстанты-локаторы элементов
BМетод login_as(), выполняющий вход
CУтверждения assert о корректности результата
DМетод open(), открывающий URL страницы