Interaction Patterns — Code Guide
How to test every type of user interaction in forsyt.io. Every pattern here uses the page object layer — never raw Playwright in tests.
1. Page Load Validation
The most fundamental check. Every page test starts here.
Page object pattern
# In the page object
def load(self) -> None:
self.navigate(self._PATH)
self.wait_for_element(self._HEADING) # waits for DOM stable element
logger.info("SportsPage loaded.")
def is_loaded(self) -> bool:
return (
"/sports" in self._page.url # URL guard
and self.is_element_visible(self._HEADING)
)
Test pattern
@pytest.mark.smoke
@pytest.mark.e2e
def test_sports_page_loads(page: Page, settings: Settings) -> None:
sports = SportsPage(page, settings)
sports.load()
assert sports.is_loaded(), "Sports page did not reach a loaded state."
Loading state check (spinner appears then disappears)
# In page object
def wait_for_content_ready(self) -> None:
"""Wait for the loading spinner to disappear."""
# First wait for the spinner to appear (optional — it may be instant)
# Then wait for it to disappear
self._page.locator(self._LOADING_SPINNER).wait_for(state="hidden", timeout=self._timeout)
def is_content_loaded(self) -> bool:
return (
not self.is_element_visible(self._LOADING_SPINNER)
and self.is_element_visible(self._CONTENT_LIST)
)
2. Element Existence & Visibility
Check an element exists and is visible
# In page object
def is_logo_visible(self) -> bool:
return self.is_element_visible(self._LOGO)
def is_submit_button_visible(self) -> bool:
return self.is_element_visible(self._SUBMIT_BTN)
Test pattern
@pytest.mark.smoke
@pytest.mark.e2e
def test_logo_visible_on_splash(mobile_page: Page, settings: Settings) -> None:
splash = SplashPage(mobile_page, settings)
splash.load()
assert splash.is_logo_visible(), "Forsyt.io logo is not visible on the splash screen."
Check multiple elements exist (count-based)
# In page object
def event_card_count(self) -> int:
return self._page.locator(self._EVENT_CARD).count()
def test_sports_feed_has_events(page: Page, settings: Settings) -> None:
sports = SportsPage(page, settings)
sports.load()
assert sports.event_card_count() > 0, "No event cards found on the sports feed."
3. Button / Tap Interaction
Pattern: button exists → is tappable → produces visible response
# In page object — separate methods for existence and action
def is_login_button_visible(self) -> bool:
return self.is_element_visible(self._LOGIN_BTN)
def tap_login_button(self) -> None:
"""Tap the login / sign-in button."""
self.safe_click(self._LOGIN_BTN) # has built-in retry
Test: button exists
@pytest.mark.smoke
@pytest.mark.e2e
def test_login_button_exists(page: Page, settings: Settings) -> None:
login = LoginPage(page, settings)
login.load()
assert login.is_login_button_visible(), "Login submit button is not visible."
Test: button is tappable and produces a result
@pytest.mark.regression
@pytest.mark.e2e
def test_login_button_is_clickable(page: Page, settings: Settings) -> None:
login = LoginPage(page, settings)
login.load()
login.enter_username("test@example.com")
login.enter_password("password123")
login.tap_login_button()
# Assert the RESPONSE — not the click itself
# Either error appears OR we redirect — both are valid outcomes to assert
assert (
login.is_error_visible() or DashboardPage(page, settings).is_loaded()
), "No visible response after tapping login button."
Touch target size (mobile accessibility)
# In page object
def login_button_dimensions(self) -> dict:
"""Return the bounding box of the login button."""
box = self._page.locator(self._LOGIN_BTN).bounding_box()
return box or {"width": 0, "height": 0}
@pytest.mark.regression
@pytest.mark.mobile
def test_login_button_touch_target_large_enough(mobile_page: Page, settings: Settings) -> None:
"""Touch targets must be at least 44×44px per accessibility guidelines."""
login = LoginPage(mobile_page, settings)
login.load()
dims = login.login_button_dimensions()
assert dims["width"] >= 44, f"Login button too narrow: {dims['width']}px"
assert dims["height"] >= 44, f"Login button too short: {dims['height']}px"
4. Form Interactions
Fill and submit
# In page object
def enter_email(self, email: str) -> None:
self.safe_fill(self._EMAIL_INPUT, email)
def enter_password(self, password: str) -> None:
self.safe_fill(self._PASSWORD_INPUT, password)
def clear_email(self) -> None:
self._page.locator(self._EMAIL_INPUT).clear()
def submit(self) -> None:
self.safe_click(self._SUBMIT_BTN)
Test: empty form validation
@pytest.mark.regression
@pytest.mark.e2e
def test_login_empty_form_shows_validation(page: Page, settings: Settings) -> None:
login = LoginPage(page, settings)
login.load()
login.submit() # submit without filling anything
assert login.is_validation_error_visible(), "No validation error on empty form submit."
Test: field-level validation message
@pytest.mark.regression
@pytest.mark.e2e
def test_invalid_email_format_shows_error(page: Page, settings: Settings) -> None:
login = LoginPage(page, settings)
login.load()
login.enter_email("not-an-email")
login.submit()
error = login.email_validation_message()
assert error, "No validation message shown for invalid email format."
5. Navigation — Tab Switching (Bottom Nav)
The forsyt.io bottom nav has 5 tabs. Each tap must navigate to the correct page.
Component pattern
# In components/bottom_nav.py
def tap_sports(self) -> None:
self.safe_click(self._SPORTS_TAB)
def tap_live(self) -> None:
self.safe_click(self._LIVE_TAB)
def tap_my_bets(self) -> None:
self.safe_click(self._MY_BETS_TAB)
def active_tab_label(self) -> str:
"""Return the label of the currently active tab."""
return self.get_text(self._ACTIVE_TAB_LABEL)
Test: tab navigation
@pytest.mark.smoke
@pytest.mark.mobile
def test_live_tab_navigates_to_live_page(mobile_page: Page, settings: Settings) -> None:
sports = SportsPage(mobile_page, settings)
sports.load()
sports.bottom_nav.tap_live()
live = LivePage(mobile_page, settings)
assert live.is_loaded(), "Live page did not load after tapping Live tab."
Test: active tab indicator updates
@pytest.mark.regression
@pytest.mark.mobile
def test_active_tab_changes_on_navigation(mobile_page: Page, settings: Settings) -> None:
sports = SportsPage(mobile_page, settings)
sports.load()
sports.bottom_nav.tap_live()
assert sports.bottom_nav.active_tab_label() == "Live", (
"Active tab indicator did not update to 'Live'."
)
6. Vertical Scroll
Test that content below the fold is reachable and renders correctly.
Page object pattern
# In page object
def scroll_to_bottom(self) -> None:
"""Scroll to the bottom of the page content."""
self._page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
def scroll_to_element(self, selector: str) -> None:
"""Scroll until the element with selector is in the viewport."""
self._page.locator(selector).scroll_into_view_if_needed()
def is_footer_visible(self) -> bool:
return self.is_element_visible(self._FOOTER)
def load_more_events(self) -> None:
"""Scroll to trigger infinite scroll / load more."""
self.scroll_to_element(self._LOAD_MORE_TRIGGER)
self.wait_for_element(self._LOADING_SPINNER, state="hidden")
Test: scroll reveals more content
@pytest.mark.regression
@pytest.mark.mobile
def test_scroll_loads_more_events(mobile_page: Page, settings: Settings) -> None:
sports = SportsPage(mobile_page, settings)
sports.load()
count_before = sports.event_card_count()
sports.scroll_to_bottom()
sports.wait_for_element(sports._EVENT_CARD) # wait for new cards
count_after = sports.event_card_count()
assert count_after >= count_before, "Scrolling down did not load more event cards."
Test: no content cut off (no horizontal overflow)
@pytest.mark.regression
@pytest.mark.mobile
def test_no_horizontal_overflow_on_sports_page(mobile_page: Page, settings: Settings) -> None:
"""Ensure no element causes horizontal scrollbar on mobile."""
sports = SportsPage(mobile_page, settings)
sports.load()
overflow = mobile_page.evaluate("""
() => document.documentElement.scrollWidth > document.documentElement.clientWidth
""")
assert not overflow, "Page has horizontal overflow — content is wider than the viewport."
7. Horizontal Swipe / Carousel / Sliding Tabs
Swipe is a touch gesture — only meaningful in mobile emulation.
Page object pattern
# In page object
def swipe_sport_tabs_left(self) -> None:
"""Swipe the sport filter tabs to reveal more tabs on the right."""
tab_bar = self._page.locator(self._SPORT_TABS_CONTAINER)
box = tab_bar.bounding_box()
if box:
start_x = box["x"] + box["width"] * 0.8
end_x = box["x"] + box["width"] * 0.2
mid_y = box["y"] + box["height"] / 2
self._page.touchscreen.tap(start_x, mid_y)
self._page.mouse.move(start_x, mid_y)
self._page.mouse.down()
self._page.mouse.move(end_x, mid_y, steps=10)
self._page.mouse.up()
def swipe_carousel_to_next(self) -> None:
"""Advance a carousel one item to the right."""
carousel = self._page.locator(self._CAROUSEL)
box = carousel.bounding_box()
if box:
start_x = box["x"] + box["width"] * 0.75
end_x = box["x"] + box["width"] * 0.25
mid_y = box["y"] + box["height"] / 2
self._page.mouse.move(start_x, mid_y)
self._page.mouse.down()
self._page.mouse.move(end_x, mid_y, steps=15)
self._page.mouse.up()
def active_carousel_item_index(self) -> int:
"""Return 0-based index of the currently visible carousel item."""
items = self._page.locator(self._CAROUSEL_ITEM).all()
for i, item in enumerate(items):
if item.is_visible():
return i
return -1
Test: swipe reveals next carousel item
@pytest.mark.regression
@pytest.mark.mobile
def test_carousel_swipe_advances_to_next_item(mobile_page: Page, settings: Settings) -> None:
home = HomePage(mobile_page, settings)
home.load()
before = home.active_carousel_item_index()
home.swipe_carousel_to_next()
after = home.active_carousel_item_index()
assert after != before, "Carousel did not advance after swiping left."
Test: sport filter tabs are swipeable
@pytest.mark.regression
@pytest.mark.mobile
def test_sport_filter_tabs_scroll_horizontally(mobile_page: Page, settings: Settings) -> None:
sports = SportsPage(mobile_page, settings)
sports.load()
# Swipe left — should not throw an error or freeze
sports.swipe_sport_tabs_left()
# The tab bar should still be visible after swiping
assert sports.is_element_visible(sports._SPORT_TABS_CONTAINER)
8. Data Changes After Interaction
Validate that the UI reflects updated state after a user action.
Filter changes content list
# In page object
def tap_football_filter(self) -> None:
self.safe_click(self._FOOTBALL_TAB)
self.wait_for_element(self._EVENT_CARD) # wait for list to update
def first_event_sport_label(self) -> str:
return self.get_text(self._FIRST_EVENT_SPORT_LABEL)
@pytest.mark.regression
@pytest.mark.e2e
def test_football_filter_shows_football_events(page: Page, settings: Settings) -> None:
sports = SportsPage(page, settings)
sports.load()
sports.tap_football_filter()
label = sports.first_event_sport_label()
assert "Football" in label or "Soccer" in label, (
f"Expected Football event after applying filter, got: '{label}'"
)
Odds value updates (live page)
# In page object
def first_odds_value(self) -> str:
return self.get_text(self._FIRST_ODDS)
@pytest.mark.regression
@pytest.mark.mobile
def test_live_odds_change_over_time(mobile_page: Page, settings: Settings) -> None:
live = LivePage(mobile_page, settings)
live.load()
odds_before = live.first_odds_value()
mobile_page.wait_for_timeout(5_000) # wait 5 seconds for odds to update
odds_after = live.first_odds_value()
# Just verify the element is still populated — values may or may not change
assert odds_after, "Odds value disappeared from the live page."
9. Navigation Flow — Back Button & Deep Links
Browser back navigation
@pytest.mark.regression
@pytest.mark.e2e
def test_back_button_returns_to_sports_from_event(page: Page, settings: Settings) -> None:
sports = SportsPage(page, settings)
sports.load()
sports.tap_first_event() # navigate into event detail
page.go_back() # browser back
assert sports.is_loaded(), "Sports page did not restore after pressing back."
Authenticated redirect
@pytest.mark.smoke
@pytest.mark.e2e
def test_my_bets_redirects_unauthenticated_user(page: Page, settings: Settings) -> None:
my_bets = MyBetsPage(page, settings)
my_bets.load() # navigate to protected page without auth
login = LoginPage(page, settings)
assert login.is_loaded(), "Unauthenticated user was not redirected to login."
10. Error States
API / network error
# In page object
def is_error_state_visible(self) -> bool:
return self.is_element_visible(self._ERROR_STATE_CONTAINER)
def error_state_message(self) -> str:
return self.get_text(self._ERROR_STATE_MESSAGE)
@pytest.mark.regression
@pytest.mark.e2e
def test_error_state_shown_when_api_fails(page: Page, settings: Settings) -> None:
# Simulate API failure by intercepting the network
page.route("**/api/sports/events", lambda route: route.abort())
sports = SportsPage(page, settings)
sports.load()
assert sports.is_error_state_visible(), "No error state shown when API is unavailable."
assert sports.error_state_message(), "Error state is visible but has no message text."
404 page
@pytest.mark.regression
@pytest.mark.e2e
def test_404_page_renders(page: Page, settings: Settings) -> None:
page.goto(f"{settings.base_url}/this-page-does-not-exist-xyz")
page.wait_for_load_state("domcontentloaded")
assert page.url # page still loaded (not a network error)
# Check for a 404 indicator — adjust selector to match real app
has_404 = (
page.locator("[data-testid='404-heading']").is_visible()
or "404" in page.title()
or "not found" in page.content().lower()
)
assert has_404, "No 404 indicator found for a non-existent URL."
11. Console Error Detection
Catch JavaScript errors that don't break the visual UI but indicate bugs.
Page object pattern
# In page object — set up before navigation
def enable_console_error_capture(self) -> list[str]:
"""Returns a mutable list that will be populated with console errors."""
errors: list[str] = []
self._page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None)
return errors
Test pattern
@pytest.mark.regression
@pytest.mark.e2e
def test_no_console_errors_on_homepage(page: Page, settings: Settings) -> None:
errors: list[str] = []
page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None)
page.goto(settings.base_url)
page.wait_for_load_state("domcontentloaded")
# Filter out known/acceptable third-party errors if needed
real_errors = [e for e in errors if "extension" not in e.lower()]
assert not real_errors, f"Console errors on homepage: {real_errors}"
12. Visual / Screenshot Assertions (Non-AI)
For layout checks that don't need AI comparison.
Viewport check (is element in view?)
# In page object
def is_element_in_viewport(self, selector: str) -> bool:
"""Return True if the element is within the current viewport."""
return self._page.locator(selector).is_visible() # Playwright only returns True if in viewport
Screenshot for debugging (not an assertion)
@pytest.mark.visual
def test_splash_screenshot(mobile_page: Page, settings: Settings) -> None:
splash = SplashPage(mobile_page, settings)
splash.load()
path = splash.take_screenshot("splash_screen_mobile")
assert path.exists(), "Screenshot was not saved."
print(f"\n Screenshot saved: {path}")
Pattern Quick Reference
| Scenario | Page Object Method | Test Assertion |
|---|---|---|
| Page loaded | load() + is_loaded() | assert page.is_loaded() |
| Element visible | is_<element>_visible() | assert page.is_logo_visible() |
| Element count | <element>_count() | assert page.card_count() > 0 |
| Button tappable | tap_<button>() | Assert resulting state |
| Form fill | enter_<field>(value) | Assert validation / submit result |
| Tab navigation | bottom_nav.tap_<tab>() | assert page.is_loaded() on target |
| Scroll | scroll_to_bottom() | Assert new content appeared |
| Swipe | swipe_<direction>() | Assert index or content changed |
| Data change | tap_filter() | Assert list content changed |
| Back navigation | page.go_back() | Assert previous page is_loaded() |
| Error state | is_error_state_visible() | assert page.is_error_state_visible() |
| Console errors | page.on("console", ...) | assert not errors |