Skip to main content

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."

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
@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."

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

ScenarioPage Object MethodTest Assertion
Page loadedload() + is_loaded()assert page.is_loaded()
Element visibleis_<element>_visible()assert page.is_logo_visible()
Element count<element>_count()assert page.card_count() > 0
Button tappabletap_<button>()Assert resulting state
Form fillenter_<field>(value)Assert validation / submit result
Tab navigationbottom_nav.tap_<tab>()assert page.is_loaded() on target
Scrollscroll_to_bottom()Assert new content appeared
Swipeswipe_<direction>()Assert index or content changed
Data changetap_filter()Assert list content changed
Back navigationpage.go_back()Assert previous page is_loaded()
Error stateis_error_state_visible()assert page.is_error_state_visible()
Console errorspage.on("console", ...)assert not errors