Page Object Model for E-Commerce
Why Page Object Model?
Without POM, locators and actions are scattered across tests:
# Without POM - Maintenance nightmare
def test_add_to_cart():
driver.find_element(By.CSS_SELECTOR, ".product-card button.add-to-cart").click()
driver.find_element(By.CSS_SELECTOR, ".cart-icon").click()
items = driver.find_elements(By.CSS_SELECTOR, ".cart-item")
assert len(items) == 1
def test_remove_from_cart():
driver.find_element(By.CSS_SELECTOR, ".product-card button.add-to-cart").click()
driver.find_element(By.CSS_SELECTOR, ".cart-icon").click()
driver.find_element(By.CSS_SELECTOR, ".cart-item .remove-btn").click()
# If .cart-item changes to .basket-item, fix it in EVERY test
With POM, changes are isolated:
# With POM - Change once, works everywhere
def test_add_to_cart(product_page, cart_page):
product_page.add_first_product_to_cart()
cart_page.open()
assert cart_page.get_item_count() == 1
def test_remove_from_cart(product_page, cart_page):
product_page.add_first_product_to_cart()
cart_page.open()
cart_page.remove_first_item()
# Locator change? Fix it once in cart_page.py
POM Architecture
Page Objects (pages/ directory):
base_page.pyโ Common methods for all pageslogin_page.pyโ Login page interactionsproducts_page.pyโ Product listing pageproduct_detail_page.pyโ Single product pagecart_page.pyโ Shopping cartcheckout_page.pyโ Checkout flow
components/ subfolder):
header.pyโ Header component (shared across pages)footer.pyโ Footer componentcart_widget.pyโ Mini cart widget
Base Page Class
# pages/base_page.py
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
from utils.config import Config
class BasePage:
"""Base class for all page objects"""
def __init__(self, driver):
self.driver = driver
self.wait = WebDriverWait(driver, Config.EXPLICIT_WAIT)
def open(self, path=""):
"""Navigate to page"""
url = f"{Config.BASE_URL}{path}"
self.driver.get(url)
return self
def find(self, locator):
"""Find element with explicit wait"""
return self.wait.until(EC.presence_of_element_located(locator))
def find_all(self, locator):
"""Find all elements"""
return self.driver.find_elements(*locator)
def click(self, locator):
"""Wait for element and click"""
element = self.wait.until(EC.element_to_be_clickable(locator))
element.click()
return self
def type(self, locator, text):
"""Clear field and type text"""
element = self.wait.until(EC.element_to_be_clickable(locator))
element.clear()
element.send_keys(text)
return self
def get_text(self, locator):
"""Get element text"""
return self.find(locator).text
def is_visible(self, locator, timeout=5):
"""Check if element is visible"""
try:
WebDriverWait(self.driver, timeout).until(
EC.visibility_of_element_located(locator)
)
return True
except:
return False
def wait_for_url_contains(self, text):
"""Wait for URL to contain text"""
self.wait.until(EC.url_contains(text))
return self
def take_screenshot(self, name):
"""Take screenshot for debugging"""
self.driver.save_screenshot(f"screenshots/{name}.png")
Products Page
# pages/products_page.py
from selenium.webdriver.common.by import By
from pages.base_page import BasePage
class ProductsPage(BasePage):
"""Product listing page"""
# Locators
PRODUCT_CARDS = (By.CSS_SELECTOR, "[data-testid='product-card']")
PRODUCT_TITLE = (By.CSS_SELECTOR, "[data-testid='product-title']")
PRODUCT_PRICE = (By.CSS_SELECTOR, "[data-testid='product-price']")
ADD_TO_CART_BTN = (By.CSS_SELECTOR, "[data-testid='add-to-cart']")
SEARCH_INPUT = (By.CSS_SELECTOR, "[data-testid='search-input']")
SEARCH_BTN = (By.CSS_SELECTOR, "[data-testid='search-button']")
SORT_DROPDOWN = (By.CSS_SELECTOR, "[data-testid='sort-select']")
LOADING_SPINNER = (By.CSS_SELECTOR, ".loading-spinner")
def open(self):
"""Open products page"""
super().open("/products")
self.wait_for_products_to_load()
return self
def wait_for_products_to_load(self):
"""Wait for product grid to load"""
self.wait.until(EC.invisibility_of_element_located(self.LOADING_SPINNER))
self.wait.until(EC.presence_of_element_located(self.PRODUCT_CARDS))
return self
def get_all_products(self):
"""Get all product cards"""
return self.find_all(self.PRODUCT_CARDS)
def get_product_count(self):
"""Get number of products displayed"""
return len(self.get_all_products())
def get_product_by_index(self, index):
"""Get specific product by index"""
products = self.get_all_products()
if index < len(products):
return products[index]
raise IndexError(f"Product index {index} out of range")
def add_product_to_cart(self, index=0):
"""Add product to cart by index"""
product = self.get_product_by_index(index)
add_btn = product.find_element(*self.ADD_TO_CART_BTN)
add_btn.click()
# Wait for cart update animation
self.wait.until(EC.invisibility_of_element_located(
(By.CSS_SELECTOR, ".cart-updating")
))
return self
def search(self, query):
"""Search for products"""
self.type(self.SEARCH_INPUT, query)
self.click(self.SEARCH_BTN)
self.wait_for_products_to_load()
return self
def sort_by(self, option):
"""Sort products by option"""
from selenium.webdriver.support.ui import Select
select = Select(self.find(self.SORT_DROPDOWN))
select.select_by_visible_text(option)
self.wait_for_products_to_load()
return self
def get_product_titles(self):
"""Get all product titles"""
return [p.find_element(*self.PRODUCT_TITLE).text
for p in self.get_all_products()]
def get_product_prices(self):
"""Get all product prices as floats"""
prices = []
for product in self.get_all_products():
price_text = product.find_element(*self.PRODUCT_PRICE).text
# Convert "$29.99" to 29.99
price = float(price_text.replace("$", "").replace(",", ""))
prices.append(price)
return prices
Cart Page
# pages/cart_page.py
from selenium.webdriver.common.by import By
from pages.base_page import BasePage
class CartPage(BasePage):
"""Shopping cart page"""
# Locators
CART_ITEMS = (By.CSS_SELECTOR, "[data-testid='cart-item']")
ITEM_TITLE = (By.CSS_SELECTOR, "[data-testid='item-title']")
ITEM_PRICE = (By.CSS_SELECTOR, "[data-testid='item-price']")
ITEM_QUANTITY = (By.CSS_SELECTOR, "[data-testid='item-quantity']")
REMOVE_BTN = (By.CSS_SELECTOR, "[data-testid='remove-item']")
CART_TOTAL = (By.CSS_SELECTOR, "[data-testid='cart-total']")
CHECKOUT_BTN = (By.CSS_SELECTOR, "[data-testid='checkout-button']")
EMPTY_CART_MSG = (By.CSS_SELECTOR, "[data-testid='empty-cart']")
CONTINUE_SHOPPING = (By.CSS_SELECTOR, "[data-testid='continue-shopping']")
def open(self):
"""Open cart page"""
super().open("/cart")
return self
def get_items(self):
"""Get all cart items"""
return self.find_all(self.CART_ITEMS)
def get_item_count(self):
"""Get number of items in cart"""
return len(self.get_items())
def is_empty(self):
"""Check if cart is empty"""
return self.is_visible(self.EMPTY_CART_MSG)
def get_item_by_index(self, index):
"""Get cart item by index"""
items = self.get_items()
if index < len(items):
return CartItem(items[index])
raise IndexError(f"Cart item index {index} out of range")
def remove_item(self, index=0):
"""Remove item from cart"""
item = self.get_item_by_index(index)
item.remove()
# Wait for item to be removed from DOM
self.wait.until(lambda d: len(self.get_items()) < index + 1)
return self
def update_quantity(self, index, quantity):
"""Update item quantity"""
item = self.get_item_by_index(index)
item.set_quantity(quantity)
return self
def get_total(self):
"""Get cart total as float"""
total_text = self.get_text(self.CART_TOTAL)
return float(total_text.replace("$", "").replace(",", ""))
def proceed_to_checkout(self):
"""Click checkout button"""
self.click(self.CHECKOUT_BTN)
self.wait_for_url_contains("/checkout")
from pages.checkout_page import CheckoutPage
return CheckoutPage(self.driver)
class CartItem:
"""Represents a single cart item"""
def __init__(self, element):
self.element = element
@property
def title(self):
return self.element.find_element(
By.CSS_SELECTOR, "[data-testid='item-title']"
).text
@property
def price(self):
text = self.element.find_element(
By.CSS_SELECTOR, "[data-testid='item-price']"
).text
return float(text.replace("$", ""))
@property
def quantity(self):
input_el = self.element.find_element(
By.CSS_SELECTOR, "[data-testid='item-quantity']"
)
return int(input_el.get_attribute("value"))
def set_quantity(self, qty):
input_el = self.element.find_element(
By.CSS_SELECTOR, "[data-testid='item-quantity']"
)
input_el.clear()
input_el.send_keys(str(qty))
# Trigger blur/change event
input_el.send_keys(Keys.TAB)
def remove(self):
self.element.find_element(
By.CSS_SELECTOR, "[data-testid='remove-item']"
).click()
Shared Components
# pages/components/header.py
from selenium.webdriver.common.by import By
from pages.base_page import BasePage
class Header(BasePage):
"""Header component (appears on all pages)"""
LOGO = (By.CSS_SELECTOR, "[data-testid='logo']")
SEARCH_INPUT = (By.CSS_SELECTOR, "[data-testid='header-search']")
CART_ICON = (By.CSS_SELECTOR, "[data-testid='cart-icon']")
CART_COUNT = (By.CSS_SELECTOR, "[data-testid='cart-count']")
USER_MENU = (By.CSS_SELECTOR, "[data-testid='user-menu']")
LOGIN_LINK = (By.CSS_SELECTOR, "[data-testid='login-link']")
LOGOUT_BTN = (By.CSS_SELECTOR, "[data-testid='logout-btn']")
def get_cart_count(self):
"""Get current cart count"""
try:
text = self.get_text(self.CART_COUNT)
return int(text) if text else 0
except:
return 0
def go_to_cart(self):
"""Click cart icon"""
self.click(self.CART_ICON)
from pages.cart_page import CartPage
return CartPage(self.driver)
def search(self, query):
"""Search from header"""
self.type(self.SEARCH_INPUT, query)
self.find(self.SEARCH_INPUT).submit()
from pages.products_page import ProductsPage
return ProductsPage(self.driver)
def is_logged_in(self):
"""Check if user is logged in"""
return self.is_visible(self.USER_MENU)
def logout(self):
"""Logout user"""
self.click(self.USER_MENU)
self.click(self.LOGOUT_BTN)
return self
Using Page Objects in Tests
# tests/test_cart.py
import pytest
from pages.products_page import ProductsPage
from pages.cart_page import CartPage
from pages.components.header import Header
class TestCart:
def test_add_single_product_to_cart(self, driver):
"""Verify adding a product to cart"""
products_page = ProductsPage(driver).open()
header = Header(driver)
# Initial cart should be empty
assert header.get_cart_count() == 0
# Add first product
products_page.add_product_to_cart(0)
# Cart count should update
assert header.get_cart_count() == 1
def test_cart_shows_added_product(self, driver):
"""Verify cart displays correct product"""
products_page = ProductsPage(driver).open()
# Get product info before adding
product_titles = products_page.get_product_titles()
first_product_title = product_titles[0]
# Add to cart and go to cart
products_page.add_product_to_cart(0)
cart_page = Header(driver).go_to_cart()
# Verify product is in cart
assert cart_page.get_item_count() == 1
cart_item = cart_page.get_item_by_index(0)
assert cart_item.title == first_product_title
def test_remove_product_from_cart(self, driver):
"""Verify removing product from cart"""
# Setup: Add product
products_page = ProductsPage(driver).open()
products_page.add_product_to_cart(0)
# Go to cart and remove
cart_page = Header(driver).go_to_cart()
assert cart_page.get_item_count() == 1
cart_page.remove_item(0)
# Cart should be empty
assert cart_page.is_empty()
def test_update_quantity(self, driver):
"""Verify updating item quantity"""
products_page = ProductsPage(driver).open()
products_page.add_product_to_cart(0)
cart_page = Header(driver).go_to_cart()
original_total = cart_page.get_total()
# Update quantity to 3
cart_page.update_quantity(0, 3)
# Total should update (roughly 3x, accounting for formatting)
new_total = cart_page.get_total()
assert new_total > original_total
Key Takeaways
- One class per page - Clear separation of concerns
- Locators at the top - Easy to find and update
- Methods return self or new page - Enable method chaining
- Base class for common logic - DRY principle
- Components for shared UI - Header, footer, widgets
Next Lesson
With POM in place, let's dive deep into Testing the Shopping Cartโthe heart of e-commerce testing.