Python: Selenium

Теория: Page Object Model

Когда тестов становится много, каждая строчка find_element() превращается в потенциальную проблему. В один день разработчик меняет селектор — и десятки тестов падают. Чтобы избежать этого, применяют Page Object Model (POM) — архитектуру, где каждая страница сайта оформляется как отдельный класс.

Зачем нужен Page Object

Вместо того чтобы писать в тесте «найди элемент, кликни, введи текст», тест обращается к методам страницы:

login_page.login("user", "pass")

а не

driver.find_element(By.ID, "username").send_keys("user")
driver.find_element(By.ID, "password").send_keys("pass")
driver.find_element(By.CSS_SELECTOR, "button").click()

Теперь тест не зависит от конкретных локаторов. Если селектор изменился — правится только класс страницы, а не все тесты.

Структура проекта с POM

selenium-tests/
├── pages/
│   ├── base_page.py
│   └── login_page.py
├── tests/
│   └── test_login.py
└── conftest.py
  • pages/ — это библиотека, где каждая страница сайта — отдельный класс.
  • base_page.py — хранит общие методы для всех страниц.
  • login_page.py — описывает конкретные элементы и действия.

Базовый класс страницы

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

class BasePage:
    """Базовый класс со стандартными действиями"""
    def __init__(self, driver):
        self.driver = driver
        self.wait = WebDriverWait(driver, 10)

    def open(self, url):
        self.driver.get(url)

    def click(self, locator):
        """Ожидание кликабельности и клик"""
        el = self.wait.until(EC.element_to_be_clickable(locator))
        el.click()

    def type(self, locator, text):
        """Очистка и ввод текста"""
        el = self.wait.until(EC.visibility_of_element_located(locator))
        el.clear()
        el.send_keys(text)

    def text_of(self, locator):
        """Получение текста элемента"""
        el = self.wait.until(EC.visibility_of_element_located(locator))
        return el.text

Базовый класс хранит все общие операции: открытие страницы, ввод текста, клик, получение текста. Теперь каждая конкретная страница наследует этот класс и добавляет свои локаторы.

Пример страницы авторизации

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

class LoginPage(BasePage):
    URL = "https://the-internet.herokuapp.com/login"

    # Локаторы
    USERNAME = (By.ID, "username")
    PASSWORD = (By.ID, "password")
    SUBMIT = (By.CSS_SELECTOR, "button.radius")
    FLASH = (By.ID, "flash")

    def open(self):
        """Открывает страницу логина"""
        self.driver.get(self.URL)

    def login(self, username, password):
        """Авторизация"""
        self.type(self.USERNAME, username)
        self.type(self.PASSWORD, password)
        self.click(self.SUBMIT)

    def message(self):
        """Возвращает текст баннера"""
        return self.text_of(self.FLASH)

Каждый метод отражает одно действие пользователя. Если завтра на кнопке изменится CSS-селектор, правится только SUBMIT в этом файле — тесты трогать не нужно.

Тест с Page Object

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

def test_login_success(driver):
    page = LoginPage(driver)
    page.open()
    page.login("tomsmith", "SuperSecretPassword!")
    assert "You logged into a secure area!" in page.message()
## Пример с ошибочным входом
def test_login_fail(driver):
    page = LoginPage(driver)
    page.open()
    page.login("wrong", "123")
    assert "Your username is invalid!" in page.message()

Тот же класс, другой сценарий. Страница одна, тестов может быть сколько угодно.

Расширение модели

Когда страниц становится много, проект растёт как библиотека. Например:

pages/
├── base_page.py
├── login_page.py
├── profile_page.py
├── catalog_page.py
└── header_component.py

Можно выделять не только страницы, но и компоненты — например, шапку, меню, фильтр. Компонент — это тоже класс, который знает только свой кусок интерфейса.

# pages/header_component.py
from selenium.webdriver.common.by import By
from pages.base_page import BasePage

class Header(BasePage):
    SEARCH = (By.NAME, "q")
    USER_MENU = (By.CSS_SELECTOR, ".user-menu")

    def search(self, text):
        self.type(self.SEARCH, text)
        self.driver.find_element(*self.SEARCH).submit()

Компонент можно использовать внутри других страниц — как конструктор.

Преимущества POM

  1. Чистый код. В тестах нет «find_element» — только логика действий.
  2. Минимум дублирования. Если локатор изменился, правится один файл.
  3. Переиспользование. Одинаковые блоки сайта можно описать один раз и использовать в разных тестах.

Вынесение локаторов и действий

Когда проект растёт, даже с Page Object Model внутри классов страниц начинает скапливаться слишком много деталей: десятки локаторов и вспомогательных методов. Чтобы не превращать каждый Page Object в «простыню» на сотни строк, код делят на более мелкие логические части: локаторы и действия (actions).

Идея простая:

  • Локаторы — это описание элементов интерфейса (кнопки, поля, баннеры). Они не содержат логики, только селекторы.
  • Действия — это шаги пользователя (ввести логин, нажать «отправить», выйти из аккаунта).

Пример структуры проекта

selenium-tests/
├── pages/
│   ├── base_page.py
│   ├── login_page.py
│   ├── profile_page.py
│   ├── locators/
│   │   ├── login_locators.py
│   │   └── profile_locators.py
│   └── actions/
│       ├── login_actions.py
│       └── profile_actions.py
├── tests/
│   └── test_login.py
└── conftest.py

Локаторы выносятся в отдельные классы

Файл login_locators.py хранит только ссылки на элементы:

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

class LoginLocators:
    """Все селекторы страницы логина"""
    USERNAME = (By.ID, "username")
    PASSWORD = (By.ID, "password")
    SUBMIT = (By.CSS_SELECTOR, "button.radius")
    FLASH = (By.ID, "flash")
  • сразу видно всю структуру страницы;
  • проще искать ошибки при изменении DOM;
  • можно переиспользовать одни и те же селекторы в нескольких классах.

Действия описываются отдельно

# pages/actions/login_actions.py
from pages.locators.login_locators import LoginLocators

class LoginActions:
    """Действия, доступные на странице логина"""

    def __init__(self, driver):
        self.driver = driver

    def enter_username(self, text):
        field = self.driver.find_element(*LoginLocators.USERNAME)
        field.clear()
        field.send_keys(text)

    def enter_password(self, text):
        field = self.driver.find_element(*LoginLocators.PASSWORD)
        field.clear()
        field.send_keys(text)

    def submit(self):
        self.driver.find_element(*LoginLocators.SUBMIT).click()

    def get_flash_text(self):
        return self.driver.find_element(*LoginLocators.FLASH).text

Этот класс — не страница, а «набор действий». Он ничего не знает о тестах, только умеет взаимодействовать с элементами.

Страница объединяет всё воедино

Теперь login_page.py становится тонким «контейнером», который объединяет локаторы и действия:

# pages/login_page.py
from pages.base_page import BasePage
from pages.actions.login_actions import LoginActions
from pages.locators.login_locators import LoginLocators

class LoginPage(BasePage):
    URL = "https://the-internet.herokuapp.com/login"

    def __init__(self, driver):
        super().__init__(driver)
        self.actions = LoginActions(driver)

    def open(self):
        self.driver.get(self.URL)

    def login(self, username, password):
        self.actions.enter_username(username)
        self.actions.enter_password(password)
        self.actions.submit()

    def message(self):
        return self.actions.get_flash_text()

Теперь класс страницы просто связывает драйвер, локаторы и действия. Он отвечает за «высокоуровневые сценарии» вроде «войти» или «выйти».

Тест остаётся минимальным

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

def test_login_success(driver):
    page = LoginPage(driver)
    page.open()
    page.login("tomsmith", "SuperSecretPassword!")
    assert "You logged into a secure area!" in page.message()

Тест описывает только поведение, без деталей Selenium.

Пример добавления новой страницы

Если появляется страница профиля, нужно лишь:

  1. создать profile_locators.py;
  2. добавить profile_actions.py;
  3. собрать их в profile_page.py.

Использование POM с Pytest

POM связывает тесты и интерфейс так, чтобы сценарий в тесте описывал поведение, а детали DOM жили внутри классов страниц. Pytest даёт фикстуры и параметризацию, поэтому Page Object удобно создавать в одном месте и переиспользовать во всех кейсах.

Каркас: базовая страница и LoginPage

Базовый класс инкапсулирует ожидания и типовые действия. Любая страница наследует его и хранит локаторы у себя. Так тест не знает о селекторах и опирается на методы высокого уровня.

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

class BasePage:
    def __init__(self, driver, base_url=None, timeout=10):
        self.driver = driver
        self.base_url = base_url.rstrip("/") if base_url else None
        self.wait = WebDriverWait(driver, timeout)

    def open(self, path=""):
        url = path if path.startswith("http") else f"{self.base_url}/{path.lstrip('/')}"
        self.driver.get(url)

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

    def type(self, loc, text):
        el = self.wait.until(EC.visibility_of_element_located(loc))
        el.clear(); el.send_keys(text)

    def text(self, loc):
        return self.wait.until(EC.visibility_of_element_located(loc)).text

    def visible(self, loc):
        return self.wait.until(EC.visibility_of_element_located(loc))

Страница логина описывает элементы и даёт готовые шаги авторизации.

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

class LoginPage(BasePage):
    USER = (By.ID, "username")
    PASS = (By.ID, "password")
    SUBMIT = (By.CSS_SELECTOR, "button[type='submit']")
    FLASH = (By.ID, "flash")

    def open_self(self):
        self.open("/login")

    def login(self, username, password):
        self.type(self.USER, username)
        self.type(self.PASS, password)
        self.click(self.SUBMIT)

    def message(self):
        return self.text(self.FLASH)

Фикстуры Pytest для драйвера, базового URL и фабрики страниц

Фикстура создаёт браузер с одинаковыми параметрами и закрывает его после теста. Базовый URL передаётся в конструктор страницы. Фабрика даёт удобный способ получать Page Object’ы без дублирования кода.

# tests/conftest.py
import os, pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options

@pytest.fixture(scope="session")
def base_url():
    return os.getenv("BASE_URL", "https://the-internet.herokuapp.com")

@pytest.fixture
def driver():
    opts = Options()
    opts.add_argument("--window-size=1366,768")
    opts.add_argument("--headless=new")
    opts.add_argument("--disable-notifications")
    drv = webdriver.Chrome(options=opts)
    yield drv
    drv.quit()

@pytest.fixture
def pages(driver, base_url):
    def _factory(PageCls):
        return PageCls(driver, base_url)
    return _factory

Тесты читают сценарий, а не DOM

Тест позитивной авторизации опирается на методы страницы. Проверка идёт по сообщению и адресу.

# tests/auth/test_login_positive.py
import pytest
from pages.login_page import LoginPage

@pytest.mark.auth
def test_login_success(pages):
    page = pages(LoginPage)
    page.open_self()
    page.login("tomsmith", "SuperSecretPassword!")
    assert "You logged into a secure area!" in page.message()

Тест негативных вариантов показывает силу параметризации. Данные подаются таблицей, а логика остаётся прежней.

# tests/auth/test_login_negative.py
import pytest
from pages.login_page import LoginPage

CASES = [
    ("tomsmith", "wrong"),
    ("", "SuperSecretPassword!"),
    ("tomsmith", "")
]

@pytest.mark.auth
@pytest.mark.parametrize(("user","pwd"), CASES)
def test_login_failures(pages, user, pwd):
    page = pages(LoginPage)
    page.open_self()
    page.login(user, pwd)
    msg = page.message()
    assert "invalid" in msg.lower()

Компоненты как части страницы

Повторно используемые блоки выносятся в отдельные классы и подключаются к страницам. Это удобно для хедера, сайдбара, фильтров.

# pages/components/header.py
from selenium.webdriver.common.by import By
from pages.base_page import BasePage

class Header(BasePage):
    MENU = (By.CSS_SELECTOR, ".menu-toggle")
    LOGOUT = (By.CSS_SELECTOR, "a[href='/logout']")
    def toggle_menu(self): self.click(self.MENU)
    def logout(self): self.click(self.LOGOUT)

Страница «Личный кабинет» включает компонент и отдаёт сценарии поверх него.

# pages/secure_page.py from pages.base_page import BasePage from pages.components.header import Header class SecurePage(BasePage): def header(self): return Header(self.driver, self.base_url)

Маркеры, структура и независимость

Маркеры группируют сценарии по областям и дают быстрый запуск подмножества. Независимость достигается тем, что каждый тест работает со своей сессией браузера и сам приводит страницу в нужное состояние вызовом методов Page Object. Если вход нужен часто, полезна фикстура «авторизованный пользователь», которая вызывает LoginPage.login() в подготовке и выполняет logout() в завершении.

Локаторы отдельно, действия отдельно, страница как фасад

Проекты среднего размера выигрывают от деления: локаторы в одном модуле, действия в другом, а страница собирает их как фасад. Это упрощает поиск проблем при смене верстки и облегчает ревью. Если меняется селектор, правится файл локаторов; если меняется сценарий, правятся действия; тесты не затрагиваются.

Хуки для артефактов при падении

Pytest-хук сохраняет скриншот и HTML только при неуспехе. Это ускоряет анализ без засорения артефактами.

# tests/conftest.py (добавка)
import pathlib, pytest

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
    outcome = yield
    rep = outcome.get_result()
    if rep.when == "call" and rep.failed:
        drv = item.funcargs.get("driver")
        if not drv: return
        out = pathlib.Path("reports/screenshots"); out.mkdir(parents=True, exist_ok=True)
        drv.save_screenshot(str(out / f"{item.name}.png"))
        with open(out / f"{item.name}.html", "w", encoding="utf-8") as f:
            f.write(drv.page_source)

POM с Pytest сводит��я к трём опорам: базовый класс с ожиданиями и типовыми действиями, страницы с методами уровня сценариев, фикстуры, которые создают драйвер, отдают фабрику страниц и собирают артефакты при падении. Тесты остаются короткими и стабильными, а изменения на фронтенде требуют правок в одном месте — внутри соответствующего Page Object.

LoginPage и DashboardPage

Чтобы показать, как Page Object Model работает в реальном проекте, разберём две связанные страницы — LoginPage и DashboardPage. Одна отвечает за вход в систему, другая — за проверку, что пользователь действительно попал в личный кабинет.

1. Базовый класс BasePage

Он содержит общие методы: открытие страницы, ввод текста, клик и ожидания. Все остальные страницы наследуют этот класс.

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

class BasePage:
    """Базовый класс со стандартными методами"""
    def __init__(self, driver, base_url="https://the-internet.herokuapp.com"):
        self.driver = driver
        self.base_url = base_url
        self.wait = WebDriverWait(driver, 10)

    def open(self, path=""):
        """Открывает страницу по относительному пути"""
        self.driver.get(f"{self.base_url}{path}")

    def click(self, locator):
        """Ожидание кликабельности и клик"""
        el = self.wait.until(EC.element_to_be_clickable(locator))
        el.click()

    def type(self, locator, text):
        """Очистка и ввод текста"""
        el = self.wait.until(EC.visibility_of_element_located(locator))
        el.clear()
        el.send_keys(text)

    def text_of(self, locator):
        """Возвращает текст элемента"""
        el = self.wait.until(EC.visibility_of_element_located(locator))
        return el.text

2. LoginPage — страница входа

Эта страница содержит поля логина и пароля, кнопку «Login» и сообщение о результате.

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

class LoginPage(BasePage):
    """Страница авторизации"""
    PATH = "/login"

    # Локаторы
    USERNAME = (By.ID, "username")
    PASSWORD = (By.ID, "password")
    BUTTON_LOGIN = (By.CSS_SELECTOR, "button.radius")
    FLASH = (By.ID, "flash")

    def open_page(self):
        self.open(self.PATH)

    def login(self, username, password):
        """Выполняет авторизацию"""
        self.type(self.USERNAME, username)
        self.type(self.PASSWORD, password)
        self.click(self.BUTTON_LOGIN)

    def get_message(self):
        """Возвращает текст уведомления"""
        return self.text_of(self.FLASH)

3. DashboardPage — страница после входа

После успешной авторизации пользователь попадает на /secure. Эта страница используется для проверки, что вход действительно прошёл.

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

class DashboardPage(BasePage):
    """Личный кабинет пользователя"""
    PATH = "/secure"
    HEADER = (By.CSS_SELECTOR, "div.example h2")
    LOGOUT_BUTTON = (By.CSS_SELECTOR, "a.button.secondary.radius")

    def is_opened(self):
        """Проверяет, открыт ли дашборд"""
        return "/secure" in self.driver.current_url

    def header_text(self):
        """Возвращает заголовок страницы"""
        return self.text_of(self.HEADER)

    def logout(self):
        """Выход из кабинета"""
        self.click(self.LOGOUT_BUTTON)

4. Тест с использованием обеих страниц

В тесте создаются объекты страниц, и шаги записаны на «человеческом» уровне. Selenium-запросов нет — всё спрятано в Page Object’ах.

# tests/test_login_dashboard.py
from pages.login_page import LoginPage
from pages.dashboard_page import DashboardPage

def test_login_and_logout(driver):
    login = LoginPage(driver)
    dashboard = DashboardPage(driver)

    # 1. Открываем страницу логина
    login.open_page()

    # 2. Вводим корректные данные
    login.login("tomsmith", "SuperSecretPassword!")

    # 3. Проверяем, что редирект произошёл
    assert dashboard.is_opened()
    assert "Secure Area" in dashboard.header_text()

    # 4. Выходим из кабинета
    dashboard.logout()

    # 5. Проверяем, что снова оказались на логине
    assert "/login" in driver.current_url

Что здесь важно

  • Тест не знает о локаторах. Все селекторы и клики живут в классах страниц.
  • Каждый шаг читается как сценарий: login.login("user", "pass") → dashboard.is_opened() → dashboard.logout()
  • Если селекторы поменяются, правится только login_page.py или dashboard_page.py.
  • Можно добавлять негативные проверки — те же классы будут использоваться повторно.

Расширение: фикстуры и параметры

Если нужно выполнять тест на разных браузерах или стендах, в conftest.py добавляют фикстуры:

import pytest
from selenium import webdriver

@pytest.fixture
def driver():
    d = webdriver.Chrome()
    d.set_window_size(1366, 768)
    yield d
    d.quit()

И можно передавать базовый URL через переменные окружения (pytest --base-url=https://staging.site).

Рекомендуемые программы

+7 800 100 22 47

бесплатно по РФ

+7 495 085 21 62

бесплатно по Москве

108813 г. Москва, вн.тер.г. поселение Московский,
г. Московский, ул. Солнечная, д. 3А, стр. 1, помещ. 20Б/3
ОГРН 1217300010476
ИНН 7325174845