Selenium Automation in Practice: Reliable UI Tests with Explicit Waits + Page Objects (Python)
UI automation is powerful, but it gets a bad reputation because “flaky tests” waste time. The good news: most flakiness comes from a few fixable mistakes—like clicking before the page is ready, using brittle selectors, or mixing test logic with page interaction code.
In this hands-on guide, you’ll build a small, reliable Selenium setup in Python using:
pytestfor test execution- Selenium 4 with built-in Selenium Manager (no manual driver downloads in most cases)
- Explicit waits (no
time.sleep()) - A clean Page Object pattern
- Debug helpers: screenshots + HTML dump on failure
You can apply this to real apps: login flows, forms, basic regression checks, and smoke tests.
1) Install dependencies
Create a virtual environment and install:
pip install selenium pytest
That’s enough to start. Selenium 4 includes Selenium Manager, which often auto-resolves the correct browser driver. You still need a browser installed (Chrome, Edge, or Firefox).
2) Project structure
Here’s a simple, scalable layout:
ui-tests/ pages/ base.py login_page.py tests/ test_login.py conftest.py pytest.ini
This keeps “how to use the UI” (pages) separate from “what we’re validating” (tests).
3) Configure pytest
Add a pytest.ini to keep output consistent:
[pytest] addopts = -q testpaths = tests
4) Create a WebDriver fixture (headless-ready)
Create conftest.py. This provides a browser for each test and closes it afterward.
import os import pytest from selenium import webdriver from selenium.webdriver.chrome.options import Options as ChromeOptions @pytest.fixture def driver(): headless = os.getenv("HEADLESS", "1") == "1" options = ChromeOptions() if headless: # "new" headless is more consistent in modern Chrome options.add_argument("--headless=new") options.add_argument("--window-size=1280,900") options.add_argument("--disable-gpu") options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") drv = webdriver.Chrome(options=options) drv.implicitly_wait(0) # IMPORTANT: prefer explicit waits yield drv drv.quit()
Why set implicit wait to 0? Mixing implicit waits and explicit waits can create confusing delays and timing issues. For reliable tests, rely on explicit waits only.
5) Build a Base Page with explicit waits
Create pages/base.py. This file centralizes “wait until element is ready” logic.
from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC class BasePage: def __init__(self, driver, timeout=10): self.driver = driver self.wait = WebDriverWait(driver, timeout) def open(self, url: str): self.driver.get(url) def wait_visible(self, by: By, value: str): return self.wait.until(EC.visibility_of_element_located((by, value))) def wait_clickable(self, by: By, value: str): return self.wait.until(EC.element_to_be_clickable((by, value))) def click(self, by: By, value: str): self.wait_clickable(by, value).click() def type(self, by: By, value: str, text: str, clear=True): el = self.wait_visible(by, value) if clear: el.clear() el.send_keys(text) def text_of(self, by: By, value: str) -> str: return self.wait_visible(by, value).text
Key idea: waits are part of your “driver toolkit,” not sprinkled randomly in tests.
6) Create a Login Page Object
Create pages/login_page.py. This example uses stable selectors. Prefer:
data-testidattributes (best for testing)- IDs that don’t change
- CSS selectors that don’t depend on layout
from selenium.webdriver.common.by import By from pages.base import BasePage class LoginPage(BasePage): # Replace these selectors with your app’s selectors USERNAME = (By.CSS_SELECTOR, "[data-testid='username']") PASSWORD = (By.CSS_SELECTOR, "[data-testid='password']") SUBMIT = (By.CSS_SELECTOR, "[data-testid='login-submit']") ERROR = (By.CSS_SELECTOR, "[data-testid='login-error']") HEADER = (By.CSS_SELECTOR, "h1") def login(self, username: str, password: str): self.type(*self.USERNAME, text=username) self.type(*self.PASSWORD, text=password) self.click(*self.SUBMIT) def error_message(self) -> str: return self.text_of(*self.ERROR) def header_text(self) -> str: return self.text_of(*self.HEADER)
If your app doesn’t have data-testid yet, adding them is one of the highest ROI improvements you can make for stable UI automation.
7) Write a real test (assert behavior, not implementation)
Create tests/test_login.py. Point BASE_URL to your app (local dev server, staging, etc.).
import os from pages.login_page import LoginPage BASE_URL = os.getenv("BASE_URL", "http://localhost:3000/login") def test_login_invalid_shows_error(driver): page = LoginPage(driver) page.open(BASE_URL) # Small sanity check that we are on the login page assert "Login" in page.header_text() page.login("[email protected]", "not-the-password") # Assert visible behavior: the UI should show an error assert "invalid" in page.error_message().lower()
Notice what we didn’t do:
- No
time.sleep() - No brittle
xpathtied to DOM nesting - No assertions about “requests” or internal state—only user-visible outcomes
8) Add failure debugging: screenshot + page source
When a test fails in CI, you need evidence. Add an “autouse” fixture to capture artifacts.
Update conftest.py with the following (keep your existing driver() fixture too):
import pathlib import pytest ARTIFACTS = pathlib.Path("artifacts") ARTIFACTS.mkdir(exist_ok=True) @pytest.fixture(autouse=True) def debug_artifacts(request, driver): yield # If the test failed, pytest attaches info to the node rep = getattr(request.node, "rep_call", None) if rep and rep.failed: name = request.node.name.replace("/", "_") driver.save_screenshot(str(ARTIFACTS / f"{name}.png")) (ARTIFACTS / f"{name}.html").write_text(driver.page_source, encoding="utf-8") # Hook to store the test report on the node @pytest.hookimpl(hookwrapper=True, tryfirst=True) def pytest_runtest_makereport(item, call): outcome = yield rep = outcome.get_result() if rep.when == "call": item.rep_call = rep
Now a failing test produces artifacts/test_name.png and artifacts/test_name.html. That alone saves hours.
9) Common flakiness fixes you can apply immediately
-
Stop using
time.sleep()as a “wait for UI.” Use explicit waits that match intent: visible, clickable, URL change, text present, etc. -
Wait for navigation when a click triggers route changes. For example, wait for a unique element on the next page, not for an arbitrary delay.
-
Use stable selectors: ask your team to add
data-testidattributes to critical elements. -
One assertion per “reason to fail”: if a test checks five things and fails on the first, you lose signal. Keep tests focused.
-
Prefer page objects so selector updates don’t require editing 30 tests.
10) Run locally and in CI
Local run:
BASE_URL="http://localhost:3000/login" pytest
Headed mode (watch the browser) can be useful for debugging:
HEADLESS=0 BASE_URL="http://localhost:3000/login" pytest -s
In CI, keep headless on (default in this setup). Make sure your CI image has Chrome available, or use a prebuilt runner that includes it.
Where to go next
Once this is working, you can extend it safely:
pytest-xdistto parallelize tests (speed)- Test data helpers (create users via API, then validate UI)
- Better reporting (JUnit XML, HTML reports)
- Run against staging on a schedule for smoke checks
If you keep explicit waits, stable selectors, and page objects as your baseline, Selenium becomes a practical tool instead of a flaky headache—and junior/mid devs can maintain it confidently.
Leave a Reply