Now that you understand why POM matters, it is time to build one. A well-designed page object has three components: locators defined as class attributes, user actions encapsulated as methods, and return types that model page transitions. When the login method navigates from the login page to the inventory page, it should return an InventoryPage object — not None. This chaining pattern makes tests fluent and self-documenting.
Building a Complete Page Object — Step by Step
We will build page objects for a login page and an inventory page, demonstrating locator organisation, action methods, and page transition return types.
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
class LoginPage:
# ── URL ──
URL = "https://www.saucedemo.com"
# ── Locators (tuples — defined once, used everywhere) ──
USERNAME_INPUT = (By.ID, "user-name")
PASSWORD_INPUT = (By.ID, "password")
LOGIN_BUTTON = (By.ID, "login-button")
ERROR_MESSAGE = (By.CSS_SELECTOR, "[data-test='error']")
def __init__(self, driver):
self.driver = driver
self.wait = WebDriverWait(driver, 10)
# ── Actions ──
def open(self):
self.driver.get(self.URL)
self.wait.until(EC.visibility_of_element_located(self.LOGIN_BUTTON))
return self # enables chaining: LoginPage(driver).open().login(...)
def enter_username(self, username):
field = self.driver.find_element(*self.USERNAME_INPUT)
field.clear()
field.send_keys(username)
return self
def enter_password(self, password):
field = self.driver.find_element(*self.PASSWORD_INPUT)
field.clear()
field.send_keys(password)
return self
def click_login(self):
self.driver.find_element(*self.LOGIN_BUTTON).click()
def login(self, username, password):
self.enter_username(username)
self.enter_password(password)
self.click_login()
# Return the NEXT page — models the page transition
return InventoryPage(self.driver)
def login_expecting_error(self, username, password):
self.enter_username(username)
self.enter_password(password)
self.click_login()
return self # stay on login page — error is displayed here
def get_error_message(self):
element = self.wait.until(
EC.visibility_of_element_located(self.ERROR_MESSAGE)
)
return element.text
class InventoryPage:
# ── Locators ──
INVENTORY_LIST = (By.CLASS_NAME, "inventory_list")
INVENTORY_ITEM = (By.CLASS_NAME, "inventory_item")
ITEM_NAME = (By.CLASS_NAME, "inventory_item_name")
ADD_TO_CART_BTN = (By.CSS_SELECTOR, "button[data-test^='add-to-cart']")
CART_BADGE = (By.CLASS_NAME, "shopping_cart_badge")
CART_LINK = (By.CLASS_NAME, "shopping_cart_link")
def __init__(self, driver):
self.driver = driver
self.wait = WebDriverWait(driver, 10)
# Verify we actually landed on the inventory page
self.wait.until(EC.visibility_of_element_located(self.INVENTORY_LIST))
def get_product_count(self):
products = self.driver.find_elements(*self.INVENTORY_ITEM)
return len(products)
def get_product_names(self):
elements = self.driver.find_elements(*self.ITEM_NAME)
return [el.text for el in elements]
def add_product_to_cart(self, index=0):
buttons = self.driver.find_elements(*self.ADD_TO_CART_BTN)
if index < len(buttons):
buttons[index].click()
return self
def get_cart_count(self):
try:
badge = self.driver.find_element(*self.CART_BADGE)
return int(badge.text)
except Exception:
return 0
# ── Usage preview ──
print("LoginPage methods:")
print(" .open() → returns LoginPage")
print(" .login(user, pass) → returns InventoryPage")
print(" .login_expecting_error() → returns LoginPage (stays on same page)")
print(" .get_error_message() → returns string")
print()
print("InventoryPage methods:")
print(" .get_product_count() → returns int")
print(" .get_product_names() → returns list of strings")
print(" .add_product_to_cart(i) → returns InventoryPage (self)")
print(" .get_cart_count() → returns int")
self (enabling method chaining). Methods that navigate to a new page return an instance of the destination page object. login() returns InventoryPage because a successful login navigates away from the login page. login_expecting_error() returns self because a failed login stays on the login page. This convention makes tests self-documenting — you can read the return type to understand the expected navigation flow.InventoryPage.__init__ waits for the inventory list to be visible before returning. If the login failed and we are still on the login page, this wait times out with a clear error: "inventory_list not found." Without this check, the test would fail on a later step with a confusing "element not found" error that does not indicate the real problem (navigation failure).InventoryPage.get_product_count() returns 6, the page object does not assert that 6 is correct. The test does: assert inventory.get_product_count() == 6. This keeps page objects reusable — different tests may have different expectations for the same data.Common Mistakes
Mistake 1 — Returning None from navigation methods
❌ Wrong: def login(self, u, p): ... click() ... return None — the test must manually create the next page object.
✅ Correct: def login(self, u, p): ... click() ... return InventoryPage(self.driver) — the page object models the transition, and the test receives the next page ready to use.
Mistake 2 — Putting assertions inside page object methods
❌ Wrong: def login(self, u, p): ... assert "inventory" in self.driver.current_url — the page object makes assumptions about test expectations.
✅ Correct: The page object performs the action and returns the result. The test asserts: inventory = login_page.login(u, p) then assert inventory.get_product_count() == 6.