Test Writing Guide
Golden Rules
- Tests describe behaviour, not implementation —
test_user_can_place_a_bet, nottest_click_button_and_verify_modal - One assertion per test concept — a test that checks three unrelated things is three tests
- No raw Playwright in tests — all interactions go through page objects
- No selectors in tests — selectors live in page objects only
- 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)
| Fixture | Type | Description |
|---|---|---|
page | Page | Fresh page at the configured DEVICE viewport |
mobile_page | Page | Forced mobile 402×874 regardless of DEVICE |
desktop_page | Page | Forced desktop 1280×720 regardless of DEVICE |
authenticated_page | Page | Page with saved auth state + active device |
settings | Settings | The full settings object (base_url, timeouts, etc.) |
browser_context | BrowserContext | The 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
| What | Convention | Example |
|---|---|---|
| Test files | test_<feature>.py | test_sports.py |
| Test functions | test_<user action or state> | test_user_can_place_a_bet |
| Test classes | Test<Feature> (optional) | TestSportsPage |
| Page objects | <PageName>Page | SportsPage |
| Components | descriptive noun | BottomNav, ScoreCard |