Selenium

Page Object Model: Beyond the Basics

January 10, 2024
15 min read
Page Object Model: Beyond the Basics

Most teams implement POM wrong. Here's how to build a truly maintainable Selenium framework that scales to hundreds of tests.

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!

Tagged with:

#Selenium#Python#Design Patterns#POM

Found this helpful?

I'm available for consulting and full-time QA automation roles. Let's build quality together.