Skip to main content

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:

MethodWhat 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:

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 (or BaseComponent for 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=config from parent page
  • Logger is instantiated at module level: logger = get_logger(__name__)
  • Actions have descriptive method names (click_login_button not click_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)
)