Skip to main content

Test Writing Guide

Golden Rules

  1. Tests describe behaviour, not implementationtest_user_can_place_a_bet, not test_click_button_and_verify_modal
  2. One assertion per test concept — a test that checks three unrelated things is three tests
  3. No raw Playwright in tests — all interactions go through page objects
  4. No selectors in tests — selectors live in page objects only
  5. Tests must be order-independent — any test must pass in isolation with pytest tests/path/test_file.py::test_name

Anatomy of a Test File

"""
tests/e2e/test_sports.py
------------------------
Brief description of what this module tests.
"""

from __future__ import annotations

import pytest
from playwright.sync_api import Page

from config.settings import Settings
from pages.sports_page import SportsPage


# ── Module-level fixtures (optional) ──────────────────────────────────── #
# Use these when ALL tests in this module share setup (e.g. a shared client
# object, a shared API token, etc.). Do NOT use for page objects — those
# belong inside each test function.

@pytest.fixture(scope="module")
def sports_api_client() -> SportsAPIClient:
return SportsAPIClient()


# ── Tests ─────────────────────────────────────────────────────────────── #

@pytest.mark.smoke
@pytest.mark.e2e
def test_sports_page_loads(page: Page, settings: Settings) -> None:
"""Verify the sports page loads and the heading is visible."""
sports = SportsPage(page, settings)
sports.load()
assert sports.is_loaded(), "Sports page did not reach a loaded state."

Fixtures Reference

Standard fixtures (always available)

FixtureTypeDescription
pagePageFresh page at the configured DEVICE viewport
mobile_pagePageForced mobile 402×874 regardless of DEVICE
desktop_pagePageForced desktop 1280×720 regardless of DEVICE
authenticated_pagePagePage with saved auth state + active device
settingsSettingsThe full settings object (base_url, timeouts, etc.)
browser_contextBrowserContextThe raw Playwright context (rarely needed directly)

Choosing the right page fixture

# Use page when device is controlled by --device-profile / DEVICE=
def test_something(page: Page, settings: Settings) -> None: ...

# Use mobile_page when this test MUST always run on mobile
def test_bottom_nav_visible(mobile_page: Page, settings: Settings) -> None: ...

# Use desktop_page when this test MUST always run on desktop
def test_sidebar_visible(desktop_page: Page, settings: Settings) -> None: ...

# Use authenticated_page when the test requires the user to be logged in
def test_my_bets_loads(authenticated_page: Page, settings: Settings) -> None: ...

Adding a module-level fixture

@pytest.fixture(scope="module")
def shared_object() -> MyObject:
"""Created once and shared across all tests in this file."""
return MyObject()

Markers

Apply markers to control which tests run in CI:

@pytest.mark.smoke       # Must pass on every commit — fast, critical path
@pytest.mark.regression # Full suite — run nightly or before release
@pytest.mark.e2e # Real browser, real network
@pytest.mark.mobile # Intended for mobile viewport (use mobile_page)
@pytest.mark.desktop # Intended for desktop viewport (use desktop_page)
@pytest.mark.visual # Visual regression comparisons
@pytest.mark.ai # Tests exercising the AI assertion layer

Run subsets:

pytest -m smoke                        # smoke only
pytest -m "smoke and mobile" # mobile smoke only
pytest -m "e2e and not ai" # e2e excluding AI tests
pytest -m "regression or smoke" # both

Test Patterns

Basic load test

@pytest.mark.smoke
@pytest.mark.e2e
def test_homepage_loads(page: Page, settings: Settings) -> None:
page.goto(settings.base_url)
page.wait_for_load_state("domcontentloaded")
assert page.url.startswith(settings.base_url)

Page object 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()

Flow / journey test (multiple pages)

@pytest.mark.e2e
def test_user_can_navigate_from_home_to_sports(page: Page, settings: Settings) -> None:
splash = SplashPage(page, settings)
splash.load()
assert splash.is_loaded()

splash.bottom_nav.go_to_sports()

sports = SportsPage(page, settings)
assert sports.is_loaded(), "Sports page did not load after tapping sports tab."

Mobile vs desktop (parametrized)

@pytest.mark.parametrize("fixture_name", ["mobile_page", "desktop_page"])
def test_page_loads_on_all_devices(fixture_name: str, request: pytest.FixtureRequest, settings: Settings) -> None:
page = request.getfixturevalue(fixture_name)
page.goto(settings.base_url)
page.wait_for_load_state("domcontentloaded")
assert page.title()

Testing error states

@pytest.mark.regression
@pytest.mark.e2e
def test_invalid_login_shows_error(page: Page, settings: Settings) -> None:
login = LoginPage(page, settings)
login.load()
login.login("not@real.com", "wrongpassword")

assert login.is_error_visible(), "Expected an error message after bad credentials."
assert login.error_message(), "Error element is visible but has no text."

Authenticated test

@pytest.mark.e2e
def test_my_bets_visible_when_logged_in(
authenticated_page: Page, settings: Settings
) -> None:
dashboard = DashboardPage(authenticated_page, settings)
dashboard.load()
assert dashboard.navbar.is_visible()

Capturing a screenshot mid-test

def test_odds_display(page: Page, settings: Settings) -> None:
sports = SportsPage(page, settings)
sports.load()

sports.take_screenshot("odds-before-refresh")
sports.refresh()
sports.take_screenshot("odds-after-refresh")

assert sports.odds_count() > 0

What NOT to do

# ✗ Raw Playwright in test
def test_bad(page):
page.click("[data-testid='submit']") # selector leaks
page.fill("#email", "user@example.com") # selector leaks

# ✗ Multiple unrelated assertions
def test_also_bad(page, settings):
sports = SportsPage(page, settings)
sports.load()
assert sports.is_loaded()
assert sports.odds_count() > 10 # separate concern
assert sports.bottom_nav.badge_count() == 2 # separate concern

# ✗ Test depends on another test's state
def test_step_2(page, settings):
# assumes test_step_1 already ran and left the browser on a certain page
...

# ✗ Hardcoded sleep
def test_with_sleep(page, settings):
page.goto(settings.base_url)
import time
time.sleep(5) # use wait_for_element instead
...

CI Command Reference

# Local development — visible browser, verbose
HEADLESS=false pytest tests/e2e/test_homepage.py -v -s

# CI smoke suite — mobile (default device)
pytest -m smoke --tb=short

# CI smoke suite — desktop
pytest -m smoke --device-profile=desktop --tb=short

# Full regression — parallel
pytest -m regression -n auto

# Single test debug
pytest tests/e2e/test_homepage.py::test_homepage_opens -v -s

# With video and trace (debugging flaky tests)
RECORD_VIDEO=true RECORD_TRACE=true pytest tests/e2e/test_homepage.py -v

File Naming Conventions

WhatConventionExample
Test filestest_<feature>.pytest_sports.py
Test functionstest_<user action or state>test_user_can_place_a_bet
Test classesTest<Feature> (optional)TestSportsPage
Page objects<PageName>PageSportsPage
Componentsdescriptive nounBottomNav, ScoreCard