Паттерн Page Object, композиция страниц, переиспользуемые компоненты, лучшие практики
Page Object Model (POM) — паттерн, при котором каждая страница приложения инкапсулируется в отдельный класс. Тесты оперируют высокоуровневыми операциями вместо селекторов.
Без POM тесты дублируют селекторы:
# Тест 1
def test_login(page):
page.get_by_label('Email').fill('admin@test.com')
page.get_by_label('Пароль').fill('secret')
page.get_by_role('button', name='Войти').click()
# Тест 2
def test_settings(page):
page.get_by_label('Email').fill('admin@test.com') # Дублирование!
page.get_by_label('Пароль').fill('secret') # Дублирование!
page.get_by_role('button', name='Войти').click()
page.get_by_role('link', name='Настройки').click()Если селектор изменится — нужно править все тесты.
class LoginPage:
def __init__(self, page):
self.page = page
self.email_input = page.get_by_label('Email')
self.password_input = page.get_by_label('Пароль')
self.login_button = page.get_by_role('button', name='Войти')
self.error_message = page.locator('.alert-error')
async def login(self, email: str, password: str) -> 'DashboardPage':
await self.email_input.fill(email)
await self.password_input.fill(password)
await self.login_button.click()
return DashboardPage(self.page)
async def get_error_text(self) -> str:
return await self.error_message.text_content()Тест становится читаемым:
def test_valid_login(page):
login_page = LoginPage(page)
dashboard = login_page.login('admin@test.com', 'secret')
# Assertions — в тесте, не в Page Object
assert page.url.endswith('/dashboard')Локаторы в __init__: Все локаторы страницы собираются в одном месте. При изменении UI правите только Page Object.
Методы — действия пользователя: login(), open_settings(), search(query). Не click_submit_button() — а submit_form(), описывающее намерение.
Возврат следующей страницы: Метод навигации возвращает Page Object следующей страницы. Это позволяет строить цепочки.
Никаких assertions в Page Object: Page Object предоставляет данные, тест проверяет.
Хедер, футер, модальное окно — компоненты, используемые на множестве страниц:
class HeaderComponent:
def __init__(self, page):
self.page = page
self.logo = page.get_by_alt_text('Логотип')
self.profile_menu = page.get_by_role('button', name='Профиль')
self.logout_item = page.get_by_role('menuitem', name='Выйти')
async def open_profile(self) -> 'ProfilePage':
await self.profile_menu.click()
return ProfilePage(self.page)
async def logout(self) -> 'LoginPage':
await self.profile_menu.click()
await self.logout_item.click()
return LoginPage(self.page)
class DashboardPage:
def __init__(self, page):
self.page = page
self.header = HeaderComponent(page) # Композиция
self.welcome_heading = page.get_by_role('heading', name='Добро пожаловать')
self.stats_table = page.locator('.stats-table')Композиция предпочтительнее наследования. Не class DashboardPage(BasePage) — а self.header = HeaderComponent(page).
class LoginPage:
async def login(self, email: str, password: str) -> 'DashboardPage | LoginPage':
await self.email_input.fill(email)
await self.password_input.fill(password)
await self.login_button.click()
# Проверяем результат
if await self.error_message.is_visible():
return self # Остались на странице логина
return DashboardPage(self.page) # Перешли на dashboardИли через исключение:
class LoginError(Exception):
pass
class LoginPage:
async def login(self, email: str, password: str) -> 'DashboardPage':
await self.email_input.fill(email)
await self.password_input.fill(password)
await self.login_button.click()
if await self.error_message.is_visible():
raise LoginError(await self.error_message.text_content())
return DashboardPage(self.page)В SPA нет перезагрузки страницы — навигация через JavaScript:
class UserProfilePage:
def __init__(self, page):
self.page = page
self.name_field = page.get_by_label('Имя')
self.save_button = page.get_by_role('button', name='Сохранить')
async def wait_for_loaded(self):
"""Ждём появления ключевых элементов страницы."""
from playwright.sync_api import expect
expect(self.name_field).to_be_visible(timeout=10000)
return selfТест ждёт загрузки:
def test_edit_profile(logged_in_page):
profile = UserProfilePage(logged_in_page).wait_for_loaded()
profile.name_field.fill('Новое Имя')
profile.save_button.click()tests/
├── pages/
│ ├── base.py # Базовый класс (опционально)
│ ├── login_page.py
│ ├── dashboard_page.py
│ ├── components/
│ │ ├── header.py
│ │ └── modal.py
│ └── __init__.py
├── test_login.py
├── test_dashboard.py
└── conftest.py
❌ God Object — один класс для всего приложения. Каждая страница — отдельный класс.
❌ Assertions в Page Object — assert self.name_field.is_visible(). Page Object предоставляет данные, тест проверяет.
❌ Наследование вместо композиции — class DashboardPage(BasePage). Компоненты через self.header = HeaderComponent(page).
❌ Хранение тестовых данных — ADMIN_EMAIL = 'admin@test.com' в Page Object. Данные — в тестах или фикстурах.
✅ Правильный POM: один класс на страницу, локаторы в __init__, методы действий, возврат следующей страницы, композиция компонентов, assertions в тестах.
LoginPage и DashboardPage для тестового приложения. Метод login() должен возвращать DashboardPage.HeaderComponent с методами open_profile() и logout(). Подключите его к DashboardPage.login() должен поднимать LoginError при неверных данных.LoginPage(page).login(...).open_settings().Изучите интеграцию с CI/CD, чтобы запускать тесты в GitHub Actions и GitLab CI.
Вопросы ещё не добавлены
Вопросы для этой подтемы ещё не добавлены.