Skip to main content
Back to Blog
Selenium

Page Object Model: Beyond the Basics

January 10, 202415 min read
SeleniumPythonDesign PatternsPOM

Page Object Model: Beyond the Basics

Most Selenium frameworks I've seen use Page Object Model, but they're doing it wrong. After building frameworks for The Home Depot (2,300+ stores) and maintaining 300+ tests, here's what actually works.

The Standard POM Problem

Everyone starts with the textbook POM example:

class LoginPage:
    def __init__(self, driver):
        self.driver = driver
        self.username_field = (By.ID, "username")
        self.password_field = (By.ID, "password")
        self.login_button = (By.ID, "login")
    
    def login(self, username, password):
        self.driver.find_element(*self.username_field).send_keys(username)
        self.driver.find_element(*self.password_field).send_keys(password)
        self.driver.find_element(*self.login_button).click()

This looks clean, but it has serious problems:

  • Brittle locators - IDs change, tests break
  • No waits - Race conditions everywhere
  • Duplicate code - Every page re-implements basic interactions
  • Hard to test - Can't test page objects in isolation
  • Tight coupling - Changes ripple through entire framework

The Better Way: Component Composition

After years of maintenance hell, I redesigned our framework using composition:

from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait

class BaseComponent:
    def __init__(self, driver, timeout=10):
        self.driver = driver
        self.wait = WebDriverWait(driver, timeout)
    
    def find(self, locator):
        return self.wait.until(EC.presence_of_element_located(locator))
    
    def click(self, locator):
        element = self.wait.until(EC.element_to_be_clickable(locator))
        element.click()
    
    def type(self, locator, text):
        element = self.find(locator)
        element.clear()
        element.send_keys(text)
    
    def get_text(self, locator):
        return self.find(locator).text

class InputField(BaseComponent):
    def __init__(self, driver, locator):
        super().__init__(driver)
        self.locator = locator
    
    def fill(self, text):
        self.type(self.locator, text)
    
    def clear(self):
        self.find(self.locator).clear()

class Button(BaseComponent):
    def __init__(self, driver, locator):
        super().__init__(driver)
        self.locator = locator
    
    def click(self):
        super().click(self.locator)
    
    def is_enabled(self):
        return self.find(self.locator).is_enabled()

Now pages compose these reusable components:

class LoginPage(BaseComponent):
    def __init__(self, driver):
        super().__init__(driver)
        self.username = InputField(driver, (By.ID, "username"))
        self.password = InputField(driver, (By.ID, "password"))
        self.login_btn = Button(driver, (By.ID, "login"))
    
    def login(self, username, password):
        self.username.fill(username)
        self.password.fill(password)
        self.login_btn.click()
        return DashboardPage(self.driver)

Benefits:

  • Built-in waits in every component
  • Reusable across all pages
  • Easy to test components in isolation
  • Single place to fix wait logic

Dynamic Locators: The Game Changer

Static locators break when UI changes. Use dynamic locators instead:

class DynamicLocators:
    @staticmethod
    def product_by_name(product_name):
        return (By.XPATH, f"//div[@class='product'][.//h3[text()='{product_name}']]")
    
    @staticmethod
    def button_by_text(text):
        return (By.XPATH, f"//button[text()='{text}']")
    
    @staticmethod
    def row_by_id(row_id):
        return (By.CSS_SELECTOR, f"tr[data-id='{row_id}']")

class ProductPage(BaseComponent):
    def select_product(self, name):
        locator = DynamicLocators.product_by_name(name)
        self.click(locator)
    
    def click_button(self, text):
        locator = DynamicLocators.button_by_text(text)
        self.click(locator)

This saved us hundreds of hours when The Home Depot redesigned their UI.

Handling Complex Interactions

Real apps have modals, dropdowns, and dynamic content. Here's how to handle them:

class Dropdown(BaseComponent):
    def __init__(self, driver, locator):
        super().__init__(driver)
        self.locator = locator
    
    def select_by_text(self, text):
        self.click(self.locator)
        option = (By.XPATH, f"//li[text()='{text}']")
        self.click(option)
    
    def get_selected(self):
        return self.get_text(self.locator)

class Modal(BaseComponent):
    def __init__(self, driver):
        super().__init__(driver)
        self.overlay = (By.CLASS_NAME, "modal-overlay")
        self.close_btn = (By.CLASS_NAME, "modal-close")
    
    def wait_for_modal(self):
        self.wait.until(EC.visibility_of_element_located(self.overlay))
    
    def close(self):
        self.click(self.close_btn)
        self.wait.until(EC.invisibility_of_element_located(self.overlay))

class CheckoutPage(BaseComponent):
    def __init__(self, driver):
        super().__init__(driver)
        self.country_dropdown = Dropdown(driver, (By.ID, "country"))
        self.confirmation_modal = Modal(driver)
    
    def select_country(self, country):
        self.country_dropdown.select_by_text(country)
    
    def confirm_order(self):
        self.click((By.ID, "confirm-btn"))
        self.confirmation_modal.wait_for_modal()
        self.confirmation_modal.close()

Testing Strategy

Page objects should be testable without Selenium:

class LoginPage(BaseComponent):
    def __init__(self, driver):
        super().__init__(driver)
        self.username = InputField(driver, (By.ID, "username"))
        self.password = InputField(driver, (By.ID, "password"))
        self.login_btn = Button(driver, (By.ID, "login"))
        self.error_msg = (By.CLASS_NAME, "error")
    
    def login(self, username, password):
        self.username.fill(username)
        self.password.fill(password)
        self.login_btn.click()
        return DashboardPage(self.driver)
    
    def get_error_message(self):
        try:
            return self.get_text(self.error_msg)
        except TimeoutException:
            return None

# Test
def test_login_success(driver):
    login_page = LoginPage(driver)
    dashboard = login_page.login("valid_user", "valid_pass")
    assert dashboard.is_loaded()

def test_login_failure(driver):
    login_page = LoginPage(driver)
    login_page.login("invalid", "invalid")
    assert login_page.get_error_message() == "Invalid credentials"

Performance Optimization

Page objects can slow tests down if not optimized:

class BasePage(BaseComponent):
    def __init__(self, driver):
        super().__init__(driver)
        self._page_loaded = False
    
    def wait_for_page_load(self):
        if self._page_loaded:
            return
        
        # Wait for DOM ready
        self.wait.until(lambda d: d.execute_script("return document.readyState") == "complete")
        
        # Wait for AJAX
        self.wait.until(lambda d: d.execute_script("return jQuery.active == 0"))
        
        self._page_loaded = True
    
    def is_loaded(self):
        try:
            self.wait_for_page_load()
            return True
        except TimeoutException:
            return False

Real-World Example: E-Commerce Flow

Here's a complete checkout flow using our framework:

def test_complete_purchase_flow(driver):
    # Login
    login = LoginPage(driver)
    dashboard = login.login("test@example.com", "password")
    assert dashboard.is_loaded()
    
    # Browse products
    products = dashboard.goto_products()
    products.select_category("Electronics")
    products.select_product("Laptop")
    
    # Add to cart
    product_detail = ProductDetailPage(driver)
    product_detail.select_quantity(2)
    product_detail.add_to_cart()
    
    # Checkout
    cart = product_detail.goto_cart()
    assert cart.get_item_count() == 2
    
    checkout = cart.proceed_to_checkout()
    checkout.select_country("United States")
    checkout.enter_payment_info({
        "card_number": "4111111111111111",
        "expiry": "12/25",
        "cvv": "123"
    })
    
    # Confirm
    confirmation = checkout.place_order()
    assert confirmation.get_order_number() is not None
    assert confirmation.get_message() == "Order placed successfully"

Key Takeaways

  1. Composition over inheritance - Build reusable components
  2. Dynamic locators - Adapt to UI changes easily
  3. Built-in waits - Every component waits intelligently
  4. Testability - Page objects should be easy to test
  5. Performance - Cache page load states

Results at The Home Depot

After implementing these patterns:

  • Test maintenance time: 8 hours/week → 2 hours/week
  • 300+ tests with 99.5% stability
  • UI redesign took 2 days to fix, not 2 weeks
  • New team members productive in 3 days

The investment in proper POM architecture pays off every single sprint.


Questions? Check out the complete framework on GitHub or reach out on LinkedIn!

Want to see this in action?

Check out the projects and case studies behind these articles.