Shared Utilities, Base Pages, and Type Hints

8 min read

A test suite without a base page class repeats the same __init__(self, page) line in every page object. Without a constants file, hardcoded strings drift across tests until "Sign in" and "Login" both refer to the same button. Without type hints, every method call is a guess and every refactor is a search-and-replace. The shared-utility layer is what turns a collection of test scripts into a framework — small files of disciplined code that every test depends on, written once, maintained centrally. This lesson covers the three pieces that pay off most: a thin BasePage, a constants file with selector and URL groupings, and the type-hint discipline that makes mypy and Pylance work for you.

A BasePage that earns its keep

Keep it small. Five-ish helpers, no domain logic, just things every page object would otherwise re-implement:

# pages/base_page.py
from playwright.sync_api import Page, Locator
 
 
class BasePage:
    def __init__(self, page: Page):
        self.page = page
 
    def navigate(self, path: str):
        self.page.goto(path)
 
    def get_title(self) -> str:
        return self.page.title()
 
    def wait_for_page_load(self):
        self.page.wait_for_load_state("networkidle")
 
    def take_screenshot(self, name: str) -> bytes:
        return self.page.screenshot(path=f"reports/screenshots/{name}.png")

What's earned its place:

  • __init__(self, page: Page) — every page object holds a Page reference; centralising the constructor saves a line per subclass.
  • navigate(path) — thin wrapper around page.goto. Lets you swap in logging, retries, or pre-conditions without touching every page object.
  • get_title() — typed return. Subclasses don't need to import Page to use it.
  • wait_for_page_load() — encapsulates the wait strategy your team prefers (networkidle here; could be load or a custom assertion).
  • take_screenshot(name) — path is project-conventional; subclasses get consistent screenshot naming.

What does not belong: form-filling helpers (fill_form_field), assertion methods (assert_visible), feature logic (add_to_cart). Those are page-specific or test-specific and bloating the base class makes every page object a god class. If two subclasses need the same helper, lift it into BasePage. If only one does, leave it on the subclass.

Subclassing the base

# pages/login_page.py
from playwright.sync_api import Page, Locator, expect
from pages.base_page import BasePage
from utils.constants import URLs, Selectors
 
 
class LoginPage(BasePage):
    def __init__(self, page: Page):
        super().__init__(page)
        self.email_input: Locator = page.get_by_label(Selectors.LOGIN_EMAIL)
        self.password_input: Locator = page.get_by_label(Selectors.LOGIN_PASSWORD)
        self.submit_button: Locator = page.get_by_role("button", name=Selectors.LOGIN_SUBMIT)
        self.error_message: Locator = page.get_by_test_id("error-message")
 
    def goto(self):
        self.navigate(URLs.LOGIN)
 
    def login(self, email: str, password: str):
        self.email_input.fill(email)
        self.password_input.fill(password)
        self.submit_button.click()
 
    def expect_error(self, message: str):
        expect(self.error_message).to_contain_text(message)

super().__init__(page) wires up the inherited self.page. Locators are typed Locator, methods have signatures that mypy and Pylance can verify. The page object is small, focused, and reads top-to-bottom.

Constants file — the single source of truth for strings

Without it, "Sign in" and "Sign In" drift across tests. With it, they share one symbol:

# utils/constants.py
 
 
class Selectors:
    """User-facing strings used as locator names."""
    LOGIN_EMAIL = "Email"
    LOGIN_PASSWORD = "Password"
    LOGIN_SUBMIT = "Sign in"
 
    NAV_HOME = "Home"
    NAV_PRODUCTS = "Products"
    NAV_CART = "Cart"
 
    BUTTON_ADD_TO_CART = "Add to cart"
    BUTTON_CHECKOUT = "Checkout"
    BUTTON_PLACE_ORDER = "Place order"
 
 
class URLs:
    """Path fragments resolved against base_url."""
    LOGIN = "/login"
    DASHBOARD = "/dashboard"
    PRODUCTS = "/products"
    CART = "/cart"
    CHECKOUT = "/checkout"
 
 
class TestData:
    """Reusable identifiers."""
    DEFAULT_VIEWPORT = {"width": 1280, "height": 720}
    SLOW_API_TIMEOUT = 15_000

Three classes, three concerns. Page objects import what they need:

from utils.constants import Selectors, URLs
 
self.submit_button = page.get_by_role("button", name=Selectors.LOGIN_SUBMIT)
self.navigate(URLs.LOGIN)

When the marketing team renames "Sign in" to "Log in" — change the constant once, every page object follows. When the URL structure shifts from /login to /auth/login — change URLs.LOGIN once, every test follows.

The trade-off: constants add a layer of indirection. A reviewer reading Selectors.LOGIN_SUBMIT has to look up the value to know what the locator's actually finding. Push this to the team: is that worth the centralisation? For a 30-test prototype, no. For a 300-test suite where two teams contribute, almost always yes.

Type hints — the no-cost win

Python doesn't enforce type hints at runtime, but the tooling does:

  • Pylance (in VS Code) and PyCharm light up autocomplete on every typed variable.
  • mypy and pyright run as CI checks and fail on type errors before tests run.
  • Type hints are documentation that can't go stale — the IDE shows them on hover.

The discipline that pays off:

from dataclasses import dataclass
from typing import Optional
 
 
@dataclass
class UserCredentials:
    email: str
    password: str
    role: str = "tester"
 
 
def create_user(name: str, email: str, role: str = "tester") -> dict:
    """Create a test user via the API. Returns the created user dict."""
    ...
 
 
def get_admin_page(page: Page) -> Page:
    """Returns a Page already logged in as the admin user."""
    ...

Three patterns:

  • Dataclasses for any structured data (test users, API request bodies, configuration objects). Free __init__, free __repr__, free type hints, free attribute access.
  • Function signatures with parameter types and return types. Makes the call site readable.
  • Optional[T] when a value can be None. Optional[Page] is shorthand for Page | None.

For a Playwright Python suite specifically:

  • Page objects: def __init__(self, page: Page) and self.email_input: Locator = ....
  • Fixtures: def login_page(page: Page) -> LoginPage:.
  • Helpers: def create_user(...) -> dict:.

Add a pyproject.toml mypy section:

[tool.mypy]
python_version = "3.12"
strict = true
exclude = ["fixtures/", "reports/"]

mypy tests/ in CI catches type errors at PR time, not at test-failure time.

API client utility — the reusable wrapper

When your tests call page.request.post("/api/users", ...) in twenty places, lift the calls into a small ApiClient:

# utils/api_client.py
from playwright.sync_api import APIRequestContext
 
 
class ApiClient:
    def __init__(self, request: APIRequestContext):
        self.request = request
 
    def create_user(self, data: dict) -> dict:
        response = self.request.post("/api/users", json=data)
        assert response.ok, f"create_user failed: {response.status} {response.text()}"
        return response.json()
 
    def delete_user(self, user_id: int):
        response = self.request.delete(f"/api/users/{user_id}")
        assert response.ok, f"delete_user failed: {response.status}"
 
    def list_users(self) -> list[dict]:
        response = self.request.get("/api/users")
        assert response.ok
        return response.json()
 
    def login(self, email: str, password: str) -> str:
        response = self.request.post("/api/login", json={"email": email, "password": password})
        assert response.ok
        return response.json()["token"]

Wrap with a fixture:

# tests/conftest.py
import pytest
from utils.api_client import ApiClient
 
 
@pytest.fixture
def api(page) -> ApiClient:
    return ApiClient(page.request)
 
 
# Usage:
def test_user_lifecycle(page, api: ApiClient):
    user = api.create_user({"name": "Alice", "email": "alice@test.com"})
    page.goto(f"/users/{user['id']}")
    expect(page.get_by_role("heading")).to_contain_text("Alice")
    api.delete_user(user["id"])

Tests now read at the right level of abstraction — api.create_user(...) instead of page.request.post("/api/users", json=...). When the API endpoint moves from /api/users to /api/v2/users, you change one method, every test follows.

Shared code flow — from constants to test

Five layers, each importing only what's below it. A change at the bottom (a Selector name) propagates upward via imports. A change at the top (test logic) doesn't touch the bottom. Clean dependency direction is what makes the framework maintainable as it grows.

Coming from Playwright TypeScript?

The TypeScript course's "Shared Utilities and Base Pages" lesson covers the same patterns:

  • TS class BasePage { constructor(public readonly page: Page) {} } → Python class BasePage: def __init__(self, page: Page): self.page = page
  • TS enum Selectors { LoginSubmit = 'Sign in' } → Python class Selectors: LOGIN_SUBMIT = "Sign in"
  • TS interface UserCredentials { email: string; ... } → Python @dataclass class UserCredentials: email: str; ...
  • TS class ApiClient { ... } → Python identical, snake_case methods

The Python version is less verbose (no access modifiers, no readonly), with type checking that's optional but recommended (mypy/pyright). The architectural patterns transfer 1:1.

⚠️ Common mistakes

  • Letting BasePage become a god class. Every shared helper looks like a good fit at first; over a year, BasePage accumulates form-filling, table-parsing, modal-handling, screenshot-comparison utilities until it's 500 lines and every page inherits the kitchen sink. Hold the line at five or six helpers — refuse anything that's only used by one or two pages.
  • Hardcoding strings in tests despite the constants file. page.get_by_role("button", name="Sign in") directly in a test bypasses the constants entirely. Lint for it (a regex check in pre-commit) or just remove it during review. Constants only pay off when everyone uses them.
  • Skipping type hints because "Python is dynamic." Hints are pure documentation that the IDE makes useful. The cost is one declaration per parameter; the benefit is autocomplete forever, mypy catching typos in CI, and self-documenting APIs. Skip the hints and you're outsourcing the "what does this method take?" question to whoever reads the code next.

🎯 Practice task

Build out the shared-utility layer for your project. 30-40 minutes.

  1. Create pages/base_page.py with the BasePage class from earlier in this lesson.

  2. Create utils/constants.py with Selectors, URLs, and TestData classes for at least 10 Sauce Demo strings (usernames, button names, paths, error messages).

  3. Refactor an existing page object (e.g., LoginPage from chapter 5's POM lesson) to inherit from BasePage and use Selectors.* and URLs.* instead of inline strings.

  4. Run the existing test suite — confirm everything still passes. The refactor should be invisible to test code.

  5. Add type hints everywhere. Make every page-object method have parameter and return type annotations. Make every locator attribute typed Locator. Run pip install mypy && mypy pages/ — fix any errors mypy finds.

  6. Build utils/api_client.py with at least three methods (e.g., login, get_user, create_user). For Sauce Demo there's no real API, so write the methods against https://jsonplaceholder.typicode.com for practice. Wrap as a fixture in conftest.py.

  7. Write a test that uses the api fixture for setup and the page-object fixture for UI assertions:

    def test_combined(page, api, login_page):
        # api setup
        posts = api.list_posts()
        assert len(posts) == 100
        # ui flow
        login_page.goto()
        login_page.login("standard_user", "secret_sauce")
  8. Stretch: add a pyproject.toml mypy section with strict = true. Run mypy tests/ pages/ utils/ — fix every error reported. The exercise is painful the first time, fast the second time, automatic from then on.

You've got the framework's spine. The next lesson is the data layer that fills it — factories, dataclasses, and the unique-data discipline that makes parallel testing safe.

// tip to track lessons you complete and pick up where you left off across devices.