Selenium Automation in Practice: Reliable UI Tests with Explicit Waits + Page Objects (Python)

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:

  • pytest for 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-testid attributes (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 xpath tied 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-testid attributes 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-xdist to 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

Your email address will not be published. Required fields are marked *