Page Object Guide
What is a Page Object?
A Page Object is a Python class that encapsulates all interactions with one page of the application. Tests never touch raw Playwright selectors — they call typed methods on the page object instead.
Benefits:
- Selector changes require edits in one place, not across every test
- Tests read like plain English
- Retry logic, timeouts, and logging are inherited automatically
Creating a Page Object
1. Create the file
One page = one file in pages/. Name it after the page.
pages/
├── login_page.py ← LoginPage
├── dashboard_page.py ← DashboardPage
├── splash_page.py ← SplashPage ← new
└── sports_page.py ← SportsPage ← new
2. Inherit from BasePage
# pages/splash_page.py
from __future__ import annotations
from playwright.sync_api import Page
from config.settings import Settings
from core.base_page import BasePage
from utils.logger import get_logger
logger = get_logger(__name__)
class SplashPage(BasePage):
"""Forsyt.io splash / loading screen shown on first visit."""
# ── Private selector constants ──────────────────────────────────── #
# Never expose raw selectors to tests.
# Use data-testid attributes wherever possible — they survive CSS refactors.
_PATH: str = "/"
_LOGO: str = "[data-testid='splash-logo']"
_LOADING_INDICATOR: str = "[data-testid='loading-spinner']"
def __init__(self, page: Page, config: Settings) -> None:
super().__init__(page, config)
# ── Mandatory contract ───────────────────────────────────────────── #
def load(self) -> None:
"""Navigate to the splash screen and wait until the logo is visible."""
self.navigate(self._PATH)
self.wait_for_element(self._LOGO)
logger.info("SplashPage loaded.")
def is_loaded(self) -> bool:
"""Return True when the forsyt.io logo is on screen."""
return self.is_element_visible(self._LOGO)
# ── Queries ──────────────────────────────────────────────────────── #
def is_loading(self) -> bool:
"""Return True if the loading spinner is still visible."""
return self.is_element_visible(self._LOADING_INDICATOR)
3. The mandatory contract
Every page object must implement two methods:
| Method | What it must do |
|---|---|
load() | Navigate to the page AND wait until the page is ready (element visible, URL correct, etc.) |
is_loaded() | Return True only when the page is in a usable state. No navigation, no side effects. |
Selector Rules
Priority order (best → worst)
1. data-testid attribute [data-testid='login-submit']
2. ARIA role + name button[name='Sign in']
3. Stable CSS class .nav-logo (only if not dynamically generated)
4. Element + text button:has-text('Sign in')
5. XPath (last resort — fragile)
Rules
- All selectors are private class constants — prefix with
_, ALL_CAPS - Never pass a raw selector string into a test — only return typed values
- Scope component selectors to the component root — see Component Guide below
# GOOD — selector is private, method is public
class LoginPage(BasePage):
_SUBMIT_BTN: str = "[data-testid='login-submit']"
def submit(self) -> None:
self.safe_click(self._SUBMIT_BTN)
# BAD — selector leaks into the test
def test_login(page):
page.click("[data-testid='login-submit']") # ← never do this
Inherited Interaction Methods
These are available in every page object via BasePage:
Navigation
self.navigate("/sports") # navigates to base_url + /sports
self.navigate("https://forsyt.io") # absolute URL also works
Waiting
locator = self.wait_for_element("[data-testid='card']")
locator = self.wait_for_element("[data-testid='card']", state="hidden")
locator = self.wait_for_element("[data-testid='card']", timeout=5_000)
Clicking
self.safe_click("[data-testid='nav-sports']") # with retry
self.safe_click("[data-testid='overlay']", force=True) # bypass actionability
Filling inputs
self.safe_fill("[data-testid='search-input']", "Premier League")
Reading text
text: str = self.get_text("[data-testid='welcome-banner']")
Visibility checks
visible: bool = self.is_element_visible("[data-testid='error-msg']")
Screenshots
path = self.take_screenshot("sports-page-state")
path = self.screenshot_on_failure("test_sports_page_loads")
Creating a UI Component
A component is a reusable fragment used in multiple pages (navbar, bottom tab bar, modal, score card, etc.).
components/
├── navbar.py ← NavBar
├── bottom_nav.py ← BottomNav ← new
└── score_card.py ← ScoreCard ← new
Component template
# components/bottom_nav.py
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from playwright.sync_api import Page
from core.base_component import BaseComponent
if TYPE_CHECKING:
from config.settings import Settings
class BottomNav(BaseComponent):
"""
The forsyt.io bottom navigation bar (Sports, Live, My Bets, More).
Visible on all authenticated pages on mobile.
"""
# Root selector scopes ALL child lookups inside this nav
_ROOT: str = "[data-testid='bottom-nav']"
# Child selectors — relative to root, not to the whole page
_SPORTS_TAB: str = "[data-testid='tab-sports']"
_LIVE_TAB: str = "[data-testid='tab-live']"
_MY_BETS_TAB: str = "[data-testid='tab-mybets']"
_MORE_TAB: str = "[data-testid='tab-more']"
_BADGE: str = "[data-testid='nav-badge']"
def __init__(
self,
page: Page,
*,
config: Optional["Settings"] = None,
) -> None:
super().__init__(page, self._ROOT, config=config)
# ── Contract ─────────────────────────────────────────────────────── #
def is_visible(self) -> bool:
return self.root.is_visible()
# ── Actions ──────────────────────────────────────────────────────── #
def go_to_sports(self) -> None:
self.safe_click(self._SPORTS_TAB)
def go_to_live(self) -> None:
self.safe_click(self._LIVE_TAB)
def go_to_my_bets(self) -> None:
self.safe_click(self._MY_BETS_TAB)
# ── Queries ──────────────────────────────────────────────────────── #
def badge_count(self) -> int:
"""Return the notification badge count, or 0 if no badge is shown."""
if not self.is_child_visible(self._BADGE):
return 0
text = self.get_text(self._BADGE)
return int(text) if text.isdigit() else 0
Composing a component into a page
# pages/sports_page.py
from components.bottom_nav import BottomNav
class SportsPage(BasePage):
def __init__(self, page: Page, config: Settings) -> None:
super().__init__(page, config)
self.bottom_nav = BottomNav(page, config=config) # always pass config
Page Object Checklist
Before committing a new page object, verify:
- Inherits
BasePage(orBaseComponentfor components) -
load()both navigates and waits for a stable element -
is_loaded()is a pure check — no navigation, no side effects - All selectors are
_PRIVATE_CONSTANTS - No raw selector strings appear in test files
- Component constructor receives
config=configfrom parent page - Logger is instantiated at module level:
logger = get_logger(__name__) - Actions have descriptive method names (
click_login_buttonnotclick_btn) - Queries return typed values (
str,bool,int,list[str])
Common Patterns
Wait for navigation after an action
def click_sport(self, sport_name: str) -> None:
self.safe_click(f"[data-testid='sport-{sport_name}']")
# Wait for the resulting page to stabilise
self.wait_for_element("[data-testid='sport-heading']")
Handle conditional elements
def dismiss_cookie_banner(self) -> None:
"""Dismiss the cookie consent banner if it is present."""
if self.is_element_visible("[data-testid='cookie-accept']"):
self.safe_click("[data-testid='cookie-accept']")
Return self for fluent chaining
def enter_email(self, email: str) -> "LoginPage":
self.safe_fill(self._EMAIL_INPUT, email)
return self
def enter_password(self, password: str) -> "LoginPage":
self.safe_fill(self._PASSWORD_INPUT, password)
return self
# In tests:
login.enter_email("user@example.com").enter_password("secret").submit()
Assert URL after navigation
def is_loaded(self) -> bool:
return (
self._PATH in self._page.url
and self.is_element_visible(self._HEADING)
)