Selenium Automation in Practice: Reliable UI Tests with Page Objects + Explicit Waits (Python)
Browser automation is powerful—and notoriously flaky when done naively. If you’ve ever written Selenium tests that “sometimes pass,” the usual culprits are timing, unstable locators, and tests that do too much in one place.
This guide walks through a practical, junior/mid-friendly approach to building reliable Selenium UI tests in Python using:
- Explicit waits (instead of
time.sleep()) - Page Object Model (POM) to keep tests readable and maintainable
- Stable element locators and small helper utilities
- Pytest structure that scales beyond a handful of tests
Project setup
Install dependencies:
python -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate pip install selenium pytest
You’ll also need a browser driver. Newer Selenium versions can often manage drivers automatically, but if that fails in your environment, install the driver manually (ChromeDriver for Chrome, GeckoDriver for Firefox) and ensure it’s on your PATH.
Suggested structure:
tests/ conftest.py test_login.py pages/ base_page.py login_page.py dashboard_page.py utils/ waits.py
Rule #1: Replace sleeps with explicit waits
time.sleep(2) “works” until it doesn’t—CI is slower, elements render later, network jitter happens. Instead, use Selenium’s WebDriverWait with expected conditions.
Create a tiny wait helper in utils/waits.py:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def wait_visible(driver, locator, timeout=10): return WebDriverWait(driver, timeout).until( EC.visibility_of_element_located(locator) ) def wait_clickable(driver, locator, timeout=10): return WebDriverWait(driver, timeout).until( EC.element_to_be_clickable(locator) ) def wait_gone(driver, locator, timeout=10): return WebDriverWait(driver, timeout).until( EC.invisibility_of_element_located(locator) )
These helpers standardize behavior across tests and make intent obvious.
Rule #2: Use Page Objects to keep tests clean
The Page Object Model (POM) moves “how to use the UI” into page classes, so tests can focus on “what should happen.”
Create pages/base_page.py:
from selenium.webdriver.common.by import By from utils.waits import wait_visible, wait_clickable class BasePage: def __init__(self, driver, base_url): self.driver = driver self.base_url = base_url.rstrip("/") def open(self, path="/"): self.driver.get(f"{self.base_url}{path}") return self def find_visible(self, locator, timeout=10): return wait_visible(self.driver, locator, timeout) def click(self, locator, timeout=10): el = wait_clickable(self.driver, locator, timeout) el.click() return el def type(self, locator, text, clear=True, timeout=10): el = self.find_visible(locator, timeout) if clear: el.clear() el.send_keys(text) return el
Now a login page object in pages/login_page.py:
from selenium.webdriver.common.by import By from pages.base_page import BasePage class LoginPage(BasePage): # Prefer stable selectors: data-testid, data-qa, or unique IDs. EMAIL = (By.CSS_SELECTOR, "[data-testid='email']") PASSWORD = (By.CSS_SELECTOR, "[data-testid='password']") SUBMIT = (By.CSS_SELECTOR, "[data-testid='login-submit']") ERROR = (By.CSS_SELECTOR, "[data-testid='login-error']") def open(self): return super().open("/login") def login(self, email, password): self.type(self.EMAIL, email) self.type(self.PASSWORD, password) self.click(self.SUBMIT) return self
And a dashboard page object in pages/dashboard_page.py:
from selenium.webdriver.common.by import By from pages.base_page import BasePage class DashboardPage(BasePage): TITLE = (By.CSS_SELECTOR, "[data-testid='dashboard-title']") USER_MENU = (By.CSS_SELECTOR, "[data-testid='user-menu']") def is_loaded(self): self.find_visible(self.TITLE, timeout=15) return True
Pro tip: If your app doesn’t have data-testid attributes, ask your team to add them. It’s one of the best ROI improvements you can make for test stability.
Pytest driver fixture: consistent browser setup
Create tests/conftest.py to manage driver lifecycle:
import os import pytest from selenium import webdriver from selenium.webdriver.chrome.options import Options as ChromeOptions @pytest.fixture def base_url(): return os.getenv("BASE_URL", "http://localhost:3000") @pytest.fixture def driver(): options = ChromeOptions() # CI-friendly: run headless unless you explicitly disable it if os.getenv("HEADLESS", "1") == "1": options.add_argument("--headless=new") options.add_argument("--window-size=1280,800") options.add_argument("--disable-gpu") options.add_argument("--no-sandbox") d = webdriver.Chrome(options=options) d.implicitly_wait(0) # Keep implicit waits OFF to avoid weird timing combos yield d d.quit()
Key callouts:
- Implicit waits are set to
0. Mixing implicit and explicit waits can lead to unpredictable delays. - Headless mode is controlled with an env var so devs can debug visually.
Writing a reliable test: success + failure paths
Now create tests/test_login.py:
from pages.login_page import LoginPage from pages.dashboard_page import DashboardPage def test_login_success(driver, base_url): login = LoginPage(driver, base_url).open() login.login("[email protected]", "correct-horse-battery-staple") dashboard = DashboardPage(driver, base_url) assert dashboard.is_loaded() def test_login_invalid_credentials_shows_error(driver, base_url): login = LoginPage(driver, base_url).open() login.login("[email protected]", "wrong-password") # Wait for the error to appear and assert its text err = login.find_visible(LoginPage.ERROR) assert "invalid" in err.text.lower() or "incorrect" in err.text.lower()
Notice what’s missing: no CSS/XPath in the tests. The test reads like a user flow, and the page objects carry the UI details.
Handling common flakiness patterns
Even with waits, a few UI patterns routinely cause instability. Here’s how to handle them cleanly.
- Toasts/spinners: wait for them to disappear before asserting a final state.
- Animations: prefer waiting for a stable element (like a page title) instead of “sleeping for animation.”
- Stale element references: re-find elements after page transitions; avoid storing a WebElement and using it later.
Example: wait for a loading overlay to vanish (add to a page object where relevant):
from selenium.webdriver.common.by import By from utils.waits import wait_gone class SomePage(BasePage): LOADING = (By.CSS_SELECTOR, "[data-testid='loading-overlay']") def wait_ready(self): wait_gone(self.driver, self.LOADING, timeout=20) return self
Better locators: what to use (and what to avoid)
Locator strategy matters as much as waits. A fragile selector means a tiny UI change breaks your whole suite.
- Best:
[data-testid='...'], unique IDs, semantic attributes designed for testing - Okay: stable text for buttons (when localized text won’t change), stable ARIA labels
- Avoid: long CSS chains,
:nth-child, auto-generated class names, brittle XPath based on layout
If you must use XPath, keep it short and purposeful. Example:
# Not terrible when no data-testid exists: (By.XPATH, "//button[normalize-space()='Sign in']")
Small utility: screenshot on failure (optional but useful)
When a CI test fails, a screenshot is often the difference between “guessing” and fixing quickly. Here’s a lightweight pattern using a pytest hook in tests/conftest.py:
import os import pytest @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): outcome = yield rep = outcome.get_result() if rep.when == "call" and rep.failed: driver = item.funcargs.get("driver") if driver: os.makedirs("artifacts", exist_ok=True) name = item.name.replace("/", "_").replace(":", "_") driver.save_screenshot(f"artifacts/{name}.png")
This creates artifacts/*.png for failed tests.
Run it locally and in CI
Local run:
BASE_URL="http://localhost:3000" HEADLESS=0 pytest -q
Headless run (CI-like):
BASE_URL="http://localhost:3000" HEADLESS=1 pytest -q
For CI, make sure your pipeline environment has a compatible browser available (or use a container image that includes Chrome/Chromium).
Checklist: a maintainable Selenium suite
- Use explicit waits everywhere (
WebDriverWait+ expected conditions) - Keep implicit waits off
- Adopt Page Objects so tests stay readable
- Use stable selectors (
data-testid/ IDs) - Make tests independent (each test sets up its own state)
- Capture artifacts (screenshots) on failures
If you follow these practices, your Selenium suite becomes less of a flaky “demo” and more of a dependable safety net that junior and mid-level developers can confidently extend.
Leave a Reply