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
- Composition over inheritance - Build reusable components
- Dynamic locators - Adapt to UI changes easily
- Built-in waits - Every component waits intelligently
- Testability - Page objects should be easy to test
- 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:
Found this helpful?
I'm available for consulting and full-time QA automation roles. Let's build quality together.