📖 Теория: Page Object Model

← К оглавлению урока

⚡ Суть POM за 2 минуты

POM = каждая страница → класс. Локаторы и действия — в классе. Тест вызывает методы.

  • pages/login_page.pyLoginPage.enter_username(), click_login()
  • pages/inventory_page.pyInventoryPage.get_items_amount(), add_item_to_cart()
  • tests/test_login.py → вызывает методы, не трогает локаторы
  • При изменении UI правим только класс страницы, тесты — не трогаем

Проблема: тесты без POM

Представим, что нужно написать несколько тестов для страницы логина saucedemo.com. Наивный подход — искать элементы прямо в каждом тесте:

# tests/test_login_naive.py
# Проблема: локаторы дублируются в КАЖДОМ тесте

def test_valid_login(driver):
    driver.find_element(By.ID, "user-name").send_keys("standard_user")
    driver.find_element(By.ID, "password").send_keys("secret_sauce")
    driver.find_element(By.ID, "login-button").click()
    assert "inventory.html" in driver.current_url

def test_invalid_password(driver):
    driver.find_element(By.ID, "user-name").send_keys("standard_user")  # дубль
    driver.find_element(By.ID, "password").send_keys("wrong")             # дубль
    driver.find_element(By.ID, "login-button").click()                    # дубль
    error = driver.find_element(By.CLASS_NAME, "error-message-container")
    assert "do not match" in error.text
Минусы такого подхода (из лекции):
  • Дублирование кода — одинаковые строки поиска элементов во всех тестах
  • Сложность поддержки — если id="user-name" изменится на id="username", нужно обновить каждый тест вручную
  • Сложность масштабирования — чем больше тестов, тем хаотичнее разрозненные локаторы
  • Увеличение времени написания — каждый новый тест требует ручного поиска нужных локаторов

Решение: Page Object Model

Page Object Model (POM) — паттерн проектирования для автоматизированного тестирования UI. Суть: каждая страница приложения описывается отдельным классом, который инкапсулирует все локаторы и методы работы с элементами этой страницы.

Основные принципы POM

  • Отделение логики тестов от локаторов — тест не знает, как найти элемент, только что с ним делать
  • Локаторы хранятся в классах страниц — один класс = одна страница = одно место для локаторов
  • Централизованное управление изменениями — при смене локатора правим только класс страницы, тесты не трогаем
  • Повторное использование — один LoginPage используется во всех тестах, где нужен логин
  • Читаемость тестовlogin_page.enter_username("user") понятнее, чем driver.find_element(By.ID, "user-name").send_keys("user")

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

Типичная структура проекта с POM:

saucedemo_tests/
├── conftest.py              # глобальные фикстуры (driver)
├── pages/
│   ├── __init__.py          # пустой файл — делает pages/ пакетом
│   ├── base_page.py         # базовый класс страницы (общие методы)
│   ├── login_page.py        # страница логина
│   ├── inventory_page.py    # страница инвентаря
│   ├── cart_page.py         # страница корзины
│   └── checkout_page.py     # страница оформления заказа
└── tests/
    ├── __init__.py          # пустой файл — делает tests/ пакетом
    ├── base_test.py         # базовый тестовый класс (инициализация)
    ├── test_login.py        # тесты логина
    ├── test_inventory.py    # тесты страницы инвентаря
    └── test_cart.py         # тесты корзины

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

В реальных проектах часто создают общий 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):
        self.driver = driver
        self.wait = WebDriverWait(driver, 10)

    def find(self, locator):
        """Ожидает появления элемента и возвращает его."""
        return self.wait.until(EC.presence_of_element_located(locator))

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

    def type_text(self, locator, text):
        """Очищает поле и вводит текст."""
        field = self.find(locator)
        field.clear()
        field.send_keys(text)

    def get_text(self, locator):
        """Возвращает текст элемента."""
        return self.find(locator).text

    def is_visible(self, locator):
        """Проверяет, что элемент видим."""
        return self.wait.until(EC.visibility_of_element_located(locator)).is_displayed()

    def find_all(self, locator):
        """Возвращает все элементы по локатору."""
        return self.wait.until(EC.presence_of_all_elements_located(locator))

Класс страницы: LoginPage

Страница логина наследуется от BasePage и добавляет специфические методы:

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


class LoginPage(BasePage):
    # Локаторы — атрибуты класса (не в методах!)
    USERNAME_INPUT = (By.ID, "user-name")
    PASSWORD_INPUT = (By.ID, "password")
    LOGIN_BUTTON   = (By.ID, "login-button")
    ERROR_MESSAGE  = (By.CLASS_NAME, "error-message-container")

    URL = "https://www.saucedemo.com/"

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

    def enter_username(self, username):
        self.type_text(self.USERNAME_INPUT, username)

    def enter_password(self, password):
        self.type_text(self.PASSWORD_INPUT, password)

    def click_login(self):
        self.click(self.LOGIN_BUTTON)

    def success_login(self, username, password):
        """Выполняет полный цикл авторизации."""
        self.enter_username(username)
        self.enter_password(password)
        self.click_login()

    def get_error_message(self):
        return self.get_text(self.ERROR_MESSAGE)
Важно: локаторы как атрибуты класса. Хранить локаторы в виде LOCATOR = (By.X, "value") на уровне класса (а не внутри методов) — лучшая практика. Это делает их видимыми сразу при чтении класса и удобными для переопределения в подклассах.

pytest-фикстуры и POM

Фикстуры pytest позволяют инициализировать driver и объекты страниц один раз для набора тестов:

# conftest.py
import pytest
from selenium import webdriver
from selenium.webdriver.chrome.service import Service as ChromeService
from webdriver_manager.chrome import ChromeDriverManager


@pytest.fixture(scope="function")
def driver():
    """Фикстура уровня функции: новый браузер для каждого теста."""
    d = webdriver.Chrome(service=ChromeService(ChromeDriverManager().install()))
    d.maximize_window()
    yield d
    d.quit()

scope="function" vs scope="class"

scope Когда создаётся Когда удаляется Когда использовать
function (по умолчанию) Перед каждым тестом После каждого теста Тесты независимы, нужно чистое состояние
class Один раз на класс После последнего теста в классе Тесты в классе не влияют друг на друга, нужна скорость
module Один раз на файл После последнего теста в файле Тесты в файле используют общее окружение
session Один раз за всю сессию После всех тестов Очень дорогая инициализация (редко для браузера)

Базовый тестовый класс: BaseTest

Паттерн из лекции — вынести инициализацию driver и Page Objects в базовый тестовый класс с autouse=True:

# tests/base_test.py
import pytest
from selenium import webdriver
from selenium.webdriver.chrome.service import Service as ChromeService
from webdriver_manager.chrome import ChromeDriverManager
from pages.login_page import LoginPage
from pages.inventory_page import InventoryPage
from pages.cart_page import CartPage


class BaseTest:
    @pytest.fixture(scope="class", autouse=True)
    def setup(self):
        # Инициализация браузера
        self.driver = webdriver.Chrome(
            service=ChromeService(ChromeDriverManager().install())
        )
        self.driver.maximize_window()
        self.driver.get("https://www.saucedemo.com/")

        # Инициализация Page Objects
        self.login_page    = LoginPage(self.driver)
        self.inventory_page = InventoryPage(self.driver)
        self.cart_page     = CartPage(self.driver)

        yield  # тесты выполняются здесь

        self.driver.quit()  # teardown — закрываем браузер

Тестовые классы наследуются от BaseTest и автоматически получают доступ ко всем атрибутам:

# tests/test_inventory.py
from tests.base_test import BaseTest


class TestInventory(BaseTest):
    def test_items_amount(self):
        # self.login_page, self.inventory_page доступны через BaseTest
        self.login_page.success_login("standard_user", "secret_sauce")
        assert self.inventory_page.get_items_amount() == 6
Преимущества наследования от BaseTest (из лекции):
  • Избавляет от дублирования — driver и страницы создаются один раз
  • Тесты становятся чище — только логика проверок, без инициализации
  • Гибкость — для смены браузера достаточно изменить только BaseTest

Когда scope="class" плохо?

Если один тест изменяет состояние браузера (переходит на другую страницу, добавляет товары в корзину), следующий тест начнётся с этого состояния. Пример:

# Неправильно: тесты зависимы при scope="class"
class TestBad(BaseTest):
    def test_add_to_cart(self):
        self.login_page.success_login("standard_user", "secret_sauce")
        self.inventory_page.add_item_to_cart("Sauce Labs Backpack")
        # Корзина теперь не пуста!

    def test_cart_empty(self):
        # ПАДАЕТ: корзина не пуста из-за предыдущего теста
        assert self.inventory_page.get_cart_count() == 0

Решение: каждый тест сам возвращает нужное состояние (например, логинится заново через login_page.open()), или использовать scope="function".