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) живут только в тестах, никогда — в страницах.