PRACTICAL GUIDE / typed Selenium Python page components
Build Python page components that fail in useful ways
Design typed Selenium page components that hide locators, survive DOM rerenders, return domain values, and fail clearly in Python test suites.
In this guide6 sections
- Put types at the boundary that tests actually use
- Model components around user jobs, not HTML fragments
- Re-locate components that the application replaces
- Read static and runtime failures as different evidence
- Roll the pattern through an existing suite without freezing delivery
- Know when the extra abstraction costs more than it saves
What you will learn
- Put types at the boundary that tests actually use
- Model components around user jobs, not HTML fragments
- Re-locate components that the application replaces
- Read static and runtime failures as different evidence
A checkout helper accepts any object, so a test passes a dictionary where the helper expects an address record. Another test keeps a WebElement from a cart row and loses it when the page rerenders. Both failures come from the same design leak: test code can reach Selenium details that should have stayed inside a component.
Type annotations help, but annotations alone are not the fix. A useful component exposes a small contract in the language of the product, scopes every lookup to the right part of the page, and returns values that remain meaningful after the DOM changes. The type checker can then find bad calls before a browser starts, while Selenium failures still carry enough context to diagnose the runtime problem.
Put types at the boundary that tests actually use
Adding WebDriver and WebElement annotations to an existing page object makes an editor quieter, but it does not create a safer test API. The important types are the ones a test author sees: the address accepted by a form, the quantity accepted by a product card, the state returned by an order row, and the page reached after an action. Those types express what the component promises. Driver types only describe how the promise is implemented.
Start by reading the public methods in a page layer as if you were writing a test without looking at the HTML. A method named find_element forces the caller to understand markup. A method named get_name_input still hands ownership of timing and interaction to the caller. Methods such as fill_shipping_address, select_quantity, and add_to_cart say what the user can do. Their parameters can represent valid inputs, and their return values can represent observable results.
Python does not enforce annotations at runtime. A call with the wrong type still runs unless the method performs explicit validation. The early failure comes from a checker such as mypy or pyright running in development and CI. That distinction matters when a team claims typing fixed a flaky suite. Static analysis can reject card.select_quantity("two"); it cannot stop React from replacing the card between two WebDriver commands.
Types also should not promise more than the browser interaction can guarantee. If clicking a button merely submits a request, returning OrderCompletePage immediately implies a transition that may not have happened yet. Either wait for the destination condition before returning that page object, or return None and let the test observe the documented result through another method. A precise None is better than a confident but false page type.
Consider a product grid. The test needs a product name, a price, a quantity control, and an add action. It does not need the card's CSS classes or a handle to its button. An immutable record gives the test a stable snapshot for assertions, while the component keeps live element access private.
from dataclasses import dataclass
from decimal import Decimal
from typing import TypeAlias
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support.ui import Select
Locator: TypeAlias = tuple[str, str]
@dataclass(frozen=True)
class ProductSummary:
name: str
price: Decimal
class ProductCard:
_NAME: Locator = (By.CSS_SELECTOR, "[data-testid='product-name']")
_PRICE: Locator = (By.CSS_SELECTOR, "[data-testid='product-price']")
_QUANTITY: Locator = (By.CSS_SELECTOR, "select[name='quantity']")
_ADD: Locator = (By.CSS_SELECTOR, "button[data-action='add']")
def __init__(self, root: WebElement) -> None:
self._root = root
def summary(self) -> ProductSummary:
name = self._root.find_element(*self._NAME).text.strip()
price_text = self._root.find_element(*self._PRICE).text.strip()
return ProductSummary(name=name, price=Decimal(price_text.removeprefix("$")))
def select_quantity(self, quantity: int) -> None:
if quantity < 1:
raise ValueError("quantity must be positive")
control = self._root.find_element(*self._QUANTITY)
Select(control).select_by_value(str(quantity))
def add_to_cart(self) -> None:
self._root.find_element(*self._ADD).click()This component makes several deliberate choices. Locators are private. Searches begin at the card root, so a second product's name cannot satisfy the first card's lookup. ProductSummary is frozen and contains domain values, so a test can retain it after navigation. The quantity method takes an integer and also checks the runtime range, because a type checker cannot prove that an integer is positive.
The price conversion is intentionally narrow. It is correct only for markup that supplies a plain dollar string such as $19.95. A site that localizes currency, uses thousands separators, or stores a machine-readable amount in an attribute needs a parser that matches that contract. Hiding the conversion inside the component gives the team one place to make that change. It does not make the simple parser universal.
Storing the card's root element is also a conscious limit. It works well when product cards remain mounted during the interaction. If selecting a quantity causes the framework to replace the whole card, the instance becomes stale. Do not disguise that lifecycle with a broad retry. Use a locator-backed component for markup that is designed to rerender.
Model components around user jobs, not HTML fragments
A component deserves an object when it has behavior, repeated structure, or an independent lifecycle. Wrapping every div produces a second DOM written in Python. That layer costs more to navigate than the page itself and gives types no useful business meaning. A shipping form, filter panel, order row, or product card is usually a sensible boundary because a user can describe what it does.
Worked example one starts with a checkout page that contains billing and shipping forms with almost identical fields. Unscoped locators such as (By.NAME, "city") are ambiguous. The first matching input may belong to billing, which makes a shipping test fill the wrong form without raising an exception. A typed Address does not solve that. The component must first find its own root, then locate every field beneath that root.
Represent the address as a frozen dataclass rather than five positional strings. The call site becomes readable, fields cannot be silently swapped, and a checker reports a missing or misspelled attribute. Put application-specific validation where it belongs. If the product accepts blank apartment numbers, the model should allow them. If it rejects unknown country codes, the test data builder or component can validate the supported representation rather than accepting arbitrary strings and hoping the UI explains the mistake.
The component's fill method should perform only the interactions that correspond to filling the form. Submission is a separate method because tests often need to inspect client-side validation before submitting, submit an intentionally incomplete form, or compare billing and shipping behavior. Combining both actions into complete_checkout(address) removes those useful test seams.
There is another boundary decision after submission. A valid checkout might reach a review page, while an invalid address keeps the user on the form. One method cannot honestly return both page types without a union that every caller must inspect. Separate intent often reads better: submit_for_review() can wait for and return ReviewPage; submit_expecting_validation() can leave the caller with the form and expose its validation messages. The names encode the expected transition, but the test still asserts the business outcome.
That last point prevents an oracle that cannot fail. A page method named assert_invalid_address() often compares the DOM with a value created inside the same helper or merely checks that some error exists. The test should provide the expected rule and compare it with an observed message or field state. A product change that wrongly accepts the address must be able to make the test fail.
Worked example two is a search result list with repeated cards. A global locator for .result-title may pass while the wrong result contains the expected title. Component scoping changes the question from "does any title match?" to "does the title inside this result match?" Keep the list page responsible for choosing a result component by stable product identity. Keep the result component responsible for reading and acting within its root. The test then asserts that the selected product, not an accidental neighbor, has the expected data.
Avoid using list position as identity unless ordering is the behavior under test. Index zero is a visual location, not a product. A promoted result, an experiment banner, or a change in sorting can redirect every action while selectors continue to resolve. Prefer an application-owned identifier that is stable for the scenario. If the UI offers no stable identity, ask whether an accessible name or unique link target expresses the user's choice. Adding test attributes is reasonable when those user-facing signals are not unique, but the product team must treat the attribute as an automation contract.
Page components also improve diagnostics when they add context without swallowing Selenium's exception. If shipping.fill(address) fails, the stack names the shipping component and field action. A generic helper that catches every WebDriverException, retries, and finally raises RuntimeError("checkout failed") destroys the element locator, remote error, and original stack. Catch only when you can add a useful note or translate a known product state. Preserve the original exception as the cause with raise ... from error when translation is necessary.
Re-locate components that the application replaces
The most misleading component failure appears after a successful lookup. Selenium returns a reference to a particular DOM element. If the application removes that node and creates another one, the old reference does not become a reference to the replacement. A later command can raise StaleElementReferenceException even when a visually identical row is on screen.
This is not a type error. Both the old and new nodes satisfy the WebElement type. The evidence is temporal: the first lookup succeeded, an action or background update replaced the subtree, and the next command used the old reference. Browser and driver messages vary, so classify by the exception type, the timing of the rerender, and whether a fresh lookup succeeds. Do not key the diagnosis to one exact English error string.
Worked example three is an orders table that refreshes a row after cancellation. A component that stores its root element works until the button click. Reading status from that same root then fails because the server response caused the row to be replaced. The locator-backed version stores a stable row locator and resolves it for each operation.
from enum import Enum
from typing import Literal, TypeAlias
from selenium.common.exceptions import StaleElementReferenceException
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
Locator: TypeAlias = tuple[str, str]
class OrderState(Enum):
PENDING = "Pending"
CANCELLED = "Cancelled"
SHIPPED = "Shipped"
class OrderRow:
_STATUS: Locator = (By.CSS_SELECTOR, "[data-testid='order-status']")
_CANCEL: Locator = (By.CSS_SELECTOR, "button[data-action='cancel']")
def __init__(self, driver: WebDriver, root_locator: Locator) -> None:
self._driver = driver
self._root_locator = root_locator
def _root(self) -> WebElement:
return WebDriverWait(self._driver, 5).until(
EC.visibility_of_element_located(self._root_locator)
)
def state(self) -> OrderState:
text = self._root().find_element(*self._STATUS).text.strip()
return OrderState(text)
def cancel(self) -> None:
self._root().find_element(*self._CANCEL).click()
def wait_for_state_change(self, previous: OrderState) -> OrderState:
def changed(_: WebDriver) -> OrderState | Literal[False]:
try:
observed = self.state()
except StaleElementReferenceException:
return False
return observed if observed is not previous else False
return WebDriverWait(self._driver, 5).until(changed)
class OrdersPage:
_ROWS: Locator = (By.CSS_SELECTOR, "tr[data-testid='order-row'][id]")
def __init__(self, driver: WebDriver) -> None:
self._driver = driver
def rows(self) -> list[OrderRow]:
row_ids = [element.get_attribute("id") for element in self._driver.find_elements(*self._ROWS)]
return [
OrderRow(self._driver, (By.ID, row_id))
for row_id in row_ids
if row_id is not None and row_id != ""
]The wait is tied to an observable state transition, not to the outcome the scenario expects. Each call to state finds the current row and converts the displayed text to OrderState. If the product moves from Pending to Shipped after a cancel click, wait_for_state_change returns Shipped and lets the test reject it. If the product adds an unknown state, OrderState(text) raises instead of quietly returning an arbitrary string. Neither branch lets the component certify that cancellation worked.
The named wait condition reads the state once per poll. It treats a stale reference during the expected replacement window as "not ready" and tries a fresh lookup on the next poll. It does not retry the click or arbitrary component actions. Optimizing the condition by caching the WebElement would reintroduce the lifecycle bug. If the product exposes an intermediate state such as Cancelling, add that state to the product contract rather than catching ValueError and treating every unknown label as a delay.
Re-locating has a cost. It sends more find-element commands and can mask a product that churns the DOM unnecessarily. Use it where replacement is expected, not as a universal stale-element retry. If a supposedly static navigation bar is replaced several times during one click, preserving and investigating the stale failure may reveal an application regression that users also feel through lost focus or reset state.
An iframe switch can produce a similar surface failure for a different reason. A fresh lookup in the wrong browsing context will not find the row at all. Check the screenshot, current URL, frame ownership, and command sequence before deciding the node was replaced. A closed window, completed navigation, or driver session loss also invalidates assumptions, but increasing a component wait cannot repair those conditions.
Read static and runtime failures as different evidence
A strict checker and a browser run answer different questions. Treating one as proof of the other creates blind spots. Static analysis asks whether callers use the declared contract consistently. Selenium asks whether the page reached the expected state and whether each command could act on the current browser objects.
Suppose a test calls select_quantity("2") after the component changes its parameter from str to int. A checker should report an incompatible argument at the call site. The exact wording and error code depend on the checker and its version, so the durable evidence is the file, line, expected type, and supplied type. Starting Chrome to discover that mismatch wastes a session and may produce a less useful runtime error from the control.
Now suppose the call is correctly typed but the selector points at a text input instead of a select control. Static analysis is green because both are represented through Selenium's element API. The browser evidence identifies the interaction failure. Inspect the locator in the component, the element in the captured page source or screenshot, and the original exception. Do not weaken the quantity type or add Any; the contract was not the problem.
A third near-miss occurs when the page returns the right value from the wrong component. Global name locators can make a price test pass by reading the first card on the page. There may be no Selenium exception and no type error. The rejecting evidence is identity: log or expose the chosen product's immutable ID, scope the price lookup beneath that product root, and make the fixture give neighboring cards different prices. If every fixture card uses the same value, the oracle cannot reveal cross-card leakage.
Design fixtures so a wrong implementation can fail. For a billing-versus-shipping test, supply different cities. For a two-row status test, give the rows different states. For a sorting test, use data whose natural insertion order disagrees with the requested sort. These are not fabricated measurements. They are controlled inputs chosen to discriminate between implementations.
The following pytest example owns the expected cancellation outcome. It also owns the browser lifecycle, so quit() runs even when an assertion fails. The environment provides the application and Selenium endpoints; the code does not hide them behind fallback URLs that might point CI at the wrong system.
import os
from collections.abc import Iterator
import pytest
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.remote.webdriver import WebDriver
from tests.ui.components.orders import OrderState, OrdersPage
@pytest.fixture
def driver() -> Iterator[WebDriver]:
remote_url = os.environ["SELENIUM_REMOTE_URL"]
browser = webdriver.Remote(command_executor=remote_url, options=Options())
try:
yield browser
finally:
browser.quit()
def test_pending_order_can_be_cancelled(driver: WebDriver) -> None:
base_url = os.environ["TEST_BASE_URL"].rstrip("/")
driver.get(f"{base_url}/test-fixtures/orders/pending-and-shipped")
rows = OrdersPage(driver).rows()
pending = next(row for row in rows if row.state() is OrderState.PENDING)
previous = pending.state()
pending.cancel()
observed = pending.wait_for_state_change(previous)
assert observed is OrderState.CANCELLED
assert any(row.state() is OrderState.SHIPPED for row in OrdersPage(driver).rows())The second assertion is not decorative. It proves cancellation did not update every row in the fixture, a plausible selector-scoping defect. If the component accidentally clicks a page-level cancel-all button, the assertion fails. If the fixture contained only one order, that branch of bad behavior could escape.
When a failure reaches CI, retain both classes of evidence. Store the checker output as a job log. For the browser test, keep the test identity, original traceback, screenshot, relevant page source or DOM snapshot, browser and driver versions, and the effective endpoint. Redact customer data and credentials. A screenshot without the traceback cannot distinguish a product message from a failed command; a traceback without page state often cannot explain why the locator resolved incorrectly.
Roll the pattern through an existing suite without freezing delivery
A full page-layer rewrite creates a long-lived branch and forces reviewers to reason about types, locators, waits, and behavior at once. Migrate one component with frequent failures or many callers. Its defect history gives you realistic cases, and its call sites show whether the proposed contract is usable.
First, characterize current behavior with tests that can reject a wrong implementation. Record which public methods callers use, which raw elements escape, and which page transitions are assumed. Choose a component boundary and introduce domain input and output types without changing selectors. Run the checker and migrate its callers. Then move locators and waits inside the component. Separating those changes makes regression ownership clearer.
Do not keep an untyped compatibility method forever. A temporary adapter may be necessary, but give it a removal issue and keep it out of new code. Returning Any or accepting object to silence migration errors throws away the information the change was meant to add. If several legacy call shapes exist, overloads can describe them during migration, but a small number of explicit methods is usually easier to understand.
Set the checker boundary deliberately. Turning strict mode on for an entire old repository can produce thousands of unrelated errors, which teaches the team to ignore the job. Start with the component package and its migrated tests. Prevent new untyped definitions there. Expand directory by directory after the signal is clean.
Run static checks before browser tests because they are cheaper and their failures do not require a session. Still run the browser tests when typing passes. The commands below assume the project declares its checker and pytest dependencies. Pin those dependencies in the project's normal environment rather than installing an unreviewed latest version inside the job.
set -eu
python -m mypy --strict tests/ui/components tests/ui/test_orders.py
python -m pytest -q tests/ui/test_orders.pyReview type suppressions as code, not as harmless configuration. A targeted ignore can be justified when a third-party stub is incomplete. A file-wide ignore around the component layer hides exactly the interface drift you want to catch. Include the checker error code when supported and a short reason, then revisit it during dependency upgrades.
Rollout metrics should describe observed repository facts rather than made-up productivity gains. Useful counts include remaining public methods that return WebElement, migrated files excluded from strict checking, stale-element failures tagged to the component, and compatibility adapters still called. These are inventory and failure records your team can actually collect. Do not claim the pattern reduced maintenance by a percentage unless you measured comparable periods and controlled for suite changes.
Code review should trace one representative call end to end. Can the test choose the right component without knowing its selector? Can it pass invalid data without a type or runtime error? Does the component return a stable value? If the DOM replaces the root, does the chosen lifecycle strategy still make sense? Can the test's assertion fail if the product updates the wrong row? Those questions catch more defects than checking whether every function has an annotation.
Know when the extra abstraction costs more than it saves
Typed components add files, names, checker configuration, and another layer in every stack trace. A five-line smoke test against a stable internal page may not earn that cost. If one locator is used once and the interaction has no reusable behavior, keeping it in the test can be clearer. Extract it when repetition, ambiguity, or lifecycle handling appears.
Do not create a component only to satisfy a typing target. A class with click_button(), get_text(), and a public root is a thin Selenium wrapper. It still makes callers understand the markup, and now they must jump between files to do it. The abstraction should speak in product actions or observations that remain meaningful when selectors change.
Avoid locator-backed re-resolution for controls that must preserve element identity. Drag operations, focus behavior, detached-node tests, and assertions specifically about replacement may need the original WebElement. In those tests, staleness is evidence, not noise. Encapsulate the identity-sensitive operation and let the test observe the expected lifecycle instead of automatically finding a replacement.
Do not use runtime dataclass validation as a substitute for negative UI testing. Rejecting an invalid postcode inside the component prevents the browser from receiving the value, so it cannot test how the product handles that invalid input. Provide a deliberate path for raw negative-test data, or model valid and invalid scenarios with separate methods whose names make the intent clear. The component's safety rules must not erase the behavior under test.
Types are also a poor place to encode unstable content. Turning every server-provided label into an enum gives clean return types until localization or a new backend state arrives. Use an enum when the set is a real product contract and an unknown value should stop the test. Use a string or a richer record when the UI is intentionally open-ended. The trade-off is between early contract failure and tolerance for product evolution.
Finally, do not move business assertions into components to make tests shorter. A component can confirm it is on the correct page before offering actions, because otherwise its methods have no valid operating context. It should not assert that the current user receives a discount or that a rejected card shows one exact message. Those expectations belong to scenarios, and different scenarios may use the same component with opposite outcomes.
The pattern pays for itself when it removes three recurring sources of ambiguity: callers cannot pass the wrong kind of data unnoticed, locators cannot escape their component scope, and returned observations do not expire with a DOM node. Where those risks do not exist, simpler test code is a valid engineering choice.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 03Official selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 04Official docs.pytest.org reference
docs.pytest.org
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Should a Selenium page component return WebElement objects?
Usually, no. Return text, numbers, immutable records, or another page component so callers cannot depend on a browser element that may become stale. Keep WebElement access inside the smallest object that knows the component's markup.
Do Python type hints prevent stale element exceptions?
They cannot change the DOM or WebDriver lifecycle. Type hints catch incompatible calls before execution when a checker runs, while locator-backed components and explicit waits address elements replaced by a rerender.
Where should assertions live with page component objects?
Keep outcome assertions in the test because the test owns the expected business behavior. A component may reject an impossible page state or unknown UI value, but it should not decide that a particular price, role, or validation message is correct for the scenario.
Should a dynamic component store its root WebElement?
Re-locate the root by a stable locator when the application routinely replaces that subtree. Holding the element is simpler and faster for static markup, but it makes the component instance expire with the DOM node.
How do I add typing to an existing Selenium Python suite?
Begin at one frequently used component boundary and type its public inputs and return values. Run a strict type checker on that package, migrate callers, then stop exposing raw elements before expanding the pattern across the suite.
RELATED GUIDES
Continue the learning route
GUIDE 01
Build a Selenium Page Component Model for Large Suites
Master Selenium page component model with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 02
Selenium Python Tutorial: Build Your First Browser Test
Selenium Python tutorial for beginners covering setup, locators, waits, pytest fixtures, page objects, debugging, CI, and stable browser tests.
GUIDE 03
Build a Selenium Grid Capacity Dashboard with GraphQL
Build a Selenium Grid GraphQL dashboard for node health, sessions, queue depth, compatible slot capacity, stale-data handling, and actionable alerts.
GUIDE 04
Selenium Java Tutorial: Build a Maintainable Test Suite
Selenium Java tutorial for beginners covering Maven setup, WebDriver, waits, TestNG, JUnit, page objects, debugging, CI, and reliable UI tests.
GUIDE 05
Page Load Strategy and Navigation Readiness in Selenium 4
Choose a Selenium page load strategy, separate document milestones from app readiness, and diagnose navigation timeouts without hiding slow failures.