OOP for Testing
Object-Oriented Programming is the dominant paradigm for structuring test automation frameworks. The Page Object Model, the most important pattern in UI test automation, is built on OOP principles. Understanding classes, inheritance, and composition is essential for building maintainable test code.
Classes for Page Objects
A class encapsulates data (locators) and behavior (actions) for a single page or component. This separation keeps test files clean and localizes changes to one place.
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 = "/login"
EMAIL = (By.CSS_SELECTOR, "input[name='email']")
PASSWORD = (By.CSS_SELECTOR, "input[name='password']")
SUBMIT = (By.CSS_SELECTOR, "button[type='submit']")
ERROR_MESSAGE = (By.CSS_SELECTOR, ".error-message")
def __init__(self, driver):
self.driver = driver
self.wait = WebDriverWait(driver, 10)
def open(self):
self.driver.get(f"{BASE_URL}{self.URL}")
return self
def login(self, email: str, password: str) -> "DashboardPage":
self.driver.find_element(*self.EMAIL).send_keys(email)
self.driver.find_element(*self.PASSWORD).send_keys(password)
self.driver.find_element(*self.SUBMIT).click()
return DashboardPage(self.driver)
def get_error_message(self) -> str:
element = self.wait.until(EC.visibility_of_element_located(self.ERROR_MESSAGE))
return element.text
class DashboardPage:
WELCOME_HEADER = (By.CSS_SELECTOR, "h1.welcome")
LOGOUT_BUTTON = (By.CSS_SELECTOR, "[data-testid='logout']")
def __init__(self, driver):
self.driver = driver
def get_welcome_text(self) -> str:
return self.driver.find_element(*self.WELCOME_HEADER).text
def logout(self) -> "LoginPage":
self.driver.find_element(*self.LOGOUT_BUTTON).click()
return LoginPage(self.driver)
Using Page Objects in Tests
def test_login_success(driver):
dashboard = LoginPage(driver).open().login("user@test.com", "pass123")
assert dashboard.get_welcome_text() == "Welcome"
def test_login_invalid_password(driver):
login_page = LoginPage(driver).open()
login_page.login("user@test.com", "wrongpass")
assert login_page.get_error_message() == "Invalid email or password"
The test reads like a user story. Locators and interactions are hidden inside the page object. If the CSS selector for the email field changes, you update one line in LoginPage, not every test.
Inheritance: Base Page Classes
When pages share common behavior (navigation bar, footer, common waits), use a base class.
class BasePage:
def __init__(self, driver):
self.driver = driver
self.wait = WebDriverWait(driver, 10)
def get_page_title(self) -> str:
return self.driver.title
def is_loaded(self, locator) -> bool:
try:
self.wait.until(EC.presence_of_element_located(locator))
return True
except TimeoutException:
return False
def scroll_to_element(self, locator):
element = self.driver.find_element(*locator)
self.driver.execute_script("arguments[0].scrollIntoView(true);", element)
class LoginPage(BasePage):
# Inherits __init__, get_page_title, is_loaded, scroll_to_element
EMAIL = (By.CSS_SELECTOR, "input[name='email']")
# ...
class AdminPage(BasePage):
USER_TABLE = (By.CSS_SELECTOR, ".user-table")
def is_loaded(self, locator=None):
return super().is_loaded(locator or self.USER_TABLE)
The Inheritance Trap
Deep inheritance hierarchies become brittle:
# BAD: 4 levels deep — hard to understand and modify
class BasePage: ...
class AuthenticatedPage(BasePage): ...
class AdminPage(AuthenticatedPage): ...
class SuperAdminPage(AdminPage): ...
When SuperAdminPage behaves unexpectedly, you must trace through four levels to find the issue. Changes to BasePage can break all downstream classes.
Rule of thumb: Keep inheritance to one or two levels maximum.
Composition: Reusable Components
Composition means building pages from independent, reusable components instead of inheriting behavior from parent classes.
class NavigationComponent:
MENU_BUTTON = (By.CSS_SELECTOR, "[data-testid='menu']")
SEARCH_INPUT = (By.CSS_SELECTOR, "[data-testid='search']")
def __init__(self, driver):
self.driver = driver
def open_menu(self):
self.driver.find_element(*self.MENU_BUTTON).click()
def search(self, query: str):
self.driver.find_element(*self.SEARCH_INPUT).send_keys(query)
class NotificationComponent:
BELL_ICON = (By.CSS_SELECTOR, "[data-testid='notifications']")
COUNT_BADGE = (By.CSS_SELECTOR, ".notification-count")
def __init__(self, driver):
self.driver = driver
def get_count(self) -> int:
return int(self.driver.find_element(*self.COUNT_BADGE).text)
class DashboardPage:
def __init__(self, driver):
self.driver = driver
self.nav = NavigationComponent(driver) # composition
self.notifications = NotificationComponent(driver) # composition
# Dashboard-specific methods
def get_welcome_text(self) -> str:
return self.driver.find_element(By.CSS_SELECTOR, "h1.welcome").text
Using Composed Page Objects
def test_dashboard_navigation(driver):
dashboard = DashboardPage(driver)
dashboard.nav.search("settings") # uses nav component
assert dashboard.notifications.get_count() >= 0 # uses notification component
Inheritance vs Composition
| Approach | When to Use | Example |
|---|---|---|
| Inheritance | Pages share common behavior (base page utilities) | class AdminPage(BasePage) |
| Composition | Pages contain reusable UI components | self.nav = NavigationComponent(driver) |
Prefer composition. Components that you plug into pages are easier to maintain, test independently, and reuse across different pages. Reserve inheritance for a thin BasePage with shared utilities.
Encapsulation: Hiding Implementation Details
Page objects should hide implementation details from tests. Tests should never contain locators, waits, or driver commands.
# BAD: locators and waits in the test
def test_add_to_cart(driver):
driver.find_element(By.CSS_SELECTOR, ".product-card button.add").click()
WebDriverWait(driver, 10).until(
EC.text_to_be_present_in_element(
(By.CSS_SELECTOR, ".cart-count"), "1"
)
)
# GOOD: all details inside the page object
def test_add_to_cart(driver):
product_page = ProductPage(driver)
product_page.add_first_item_to_cart()
assert product_page.get_cart_count() == 1
TypeScript Page Objects
The same patterns apply in TypeScript with Playwright:
class LoginPage {
constructor(private page: Page) {}
async goto() {
await this.page.goto("/login");
}
async login(email: string, password: string): Promise<DashboardPage> {
await this.page.fill('[name="email"]', email);
await this.page.fill('[name="password"]', password);
await this.page.click('[type="submit"]');
await this.page.waitForURL("/dashboard");
return new DashboardPage(this.page);
}
async getErrorMessage(): Promise<string> {
return await this.page.textContent(".error-message") ?? "";
}
}
Practical Exercise
Build a page object framework for a simple e-commerce site:
- Create a BasePage with common utilities (wait, scroll, screenshot)
- Create a NavigationComponent used across multiple pages
- Create ProductListPage and CartPage using composition
- Write 3 tests that use your page objects without any locators in the test file
- Refactor: change a locator in a page object and verify no tests need updating
Key Takeaways
- Page Object Model is the most important design pattern in UI test automation
- One class per page; locators live in the page object, never in the test
- Methods return page objects for fluent chaining
- No assertions inside page objects — assertions belong in tests
- Prefer composition over inheritance for reusable components
- Keep inheritance to one or two levels; use BasePage for shared utilities