Building Your First Page Object — Locators, Actions and Return Types

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")
Note: The return type of each method is a critical design decision. Methods that keep you on the same page return 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.
Tip: Add a page verification check in the constructor of every page object. The 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).
Warning: Page objects should never contain assertions. Assertions belong in tests. A page object's job is to provide data and perform actions — the test decides whether the data meets expectations. If 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.

🧠 Test Yourself

A page object's login() method performs a successful login and navigates to the dashboard. What should it return?