Selenium Automation That Doesn’t Flake: Explicit Waits + Page Objects + Pytest Fixtures

Selenium Automation That Doesn’t Flake: Explicit Waits + Page Objects + Pytest Fixtures

Selenium is still one of the most practical ways to automate a real browser: clicking buttons, filling forms, and verifying workflows exactly as a user sees them. The pain point for many junior/mid devs is flakiness—tests that pass locally but randomly fail in CI or on slower machines. In this guide, you’ll build a small, reliable Selenium test setup using three fundamentals:

  • WebDriverWait + expected conditions (no time.sleep())
  • Page Object Model (POM) to keep selectors and actions organized
  • pytest fixtures for clean setup/teardown and reusable configuration

You’ll end up with a runnable test that logs into a demo site and verifies success—structured in a way you can reuse for your own apps.

Project Setup

Create a folder and install dependencies:

mkdir selenium-pom-demo cd selenium-pom-demo python -m venv .venv source .venv/bin/activate # Windows: .venv\Scripts\activate pip install selenium pytest

Driver note: Selenium needs a browser driver (ChromeDriver for Chrome, GeckoDriver for Firefox). The most reliable approach is to install a matching driver in your environment (or let your CI image provide it). For local dev:

  • Chrome: install Google Chrome and download ChromeDriver matching your version
  • Firefox: install Firefox and download GeckoDriver

Make sure the driver is on your PATH. If you run into driver issues, fix those first—once the driver is stable, the rest of this guide helps keep tests stable.

Folder Structure

Use a simple structure that scales:

selenium-pom-demo/ pages/ __init__.py base_page.py login_page.py secure_page.py tests/ test_login.py conftest.py

Base Page: Centralize Waits and Common Actions

The fastest way to reduce flaky tests is to stop racing the UI. Use explicit waits for elements to exist, be visible, or be clickable. Create 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 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, locator: str): return self.wait.until(EC.visibility_of_element_located((by, locator))) def wait_clickable(self, by: By, locator: str): return self.wait.until(EC.element_to_be_clickable((by, locator))) def click(self, by: By, locator: str): self.wait_clickable(by, locator).click() def type(self, by: By, locator: str, text: str, clear=True): el = self.wait_visible(by, locator) if clear: el.clear() el.send_keys(text) def text_of(self, by: By, locator: str) -> str: return self.wait_visible(by, locator).text

Why this matters:

  • visibility_of_element_located waits until the element is in the DOM and visible.
  • element_to_be_clickable waits until it’s visible and enabled (common “click intercepted” fix).
  • All waits are centralized, so your tests don’t become a mess of repeated code.

Page Objects: Keep Selectors and User Actions Together

Let’s automate a login flow against a public demo app. A great one is “The Internet” test site by Herokuapp. The login page is:

https://the-internet.herokuapp.com/login

Create pages/login_page.py:

from selenium.webdriver.common.by import By from .base_page import BasePage class LoginPage(BasePage): URL = "https://the-internet.herokuapp.com/login" # Locators USERNAME = (By.ID, "username") PASSWORD = (By.ID, "password") SUBMIT = (By.CSS_SELECTOR, "button[type='submit']") FLASH = (By.ID, "flash") def load(self): self.open(self.URL) def login_as(self, username: str, password: str): self.type(*self.USERNAME, text=username) self.type(*self.PASSWORD, text=password) self.click(*self.SUBMIT) def flash_message(self) -> str: # The flash includes an "x" close button and newlines; normalize it. msg = self.text_of(*self.FLASH) return " ".join(msg.split())

Now create pages/secure_page.py to represent the post-login page:

from selenium.webdriver.common.by import By from .base_page import BasePage class SecureAreaPage(BasePage): HEADER = (By.CSS_SELECTOR, "h2") FLASH = (By.ID, "flash") LOGOUT = (By.CSS_SELECTOR, "a.button.secondary.radius") def header_text(self) -> str: return self.text_of(*self.HEADER) def flash_message(self) -> str: msg = self.text_of(*self.FLASH) return " ".join(msg.split()) def logout(self): self.click(*self.LOGOUT)

This keeps your test readable: tests should express intent (“log in”, “verify header”), not implementation details (“find element by CSS selector…”).

Pytest Fixture: One Browser Instance per Test (Cleanly)

Create conftest.py at the repo root:

import os import pytest from selenium import webdriver from selenium.webdriver.chrome.options import Options as ChromeOptions def build_chrome(): opts = ChromeOptions() # Headless is great for CI; still works locally. if os.getenv("HEADLESS", "1") == "1": opts.add_argument("--headless=new") opts.add_argument("--window-size=1280,900") opts.add_argument("--disable-gpu") opts.add_argument("--no-sandbox") opts.add_argument("--disable-dev-shm-usage") return webdriver.Chrome(options=opts) @pytest.fixture def driver(): d = build_chrome() d.implicitly_wait(0) # Explicit waits only (less flake) yield d d.quit()

Key choices here:

  • implicitly_wait(0) avoids mixing implicit + explicit waits (a classic source of confusing timing bugs).
  • Headless by default keeps runs fast and consistent; you can set HEADLESS=0 locally to watch the browser.

The Actual Test: Readable, Deterministic Assertions

Create tests/test_login.py:

from pages.login_page import LoginPage from pages.secure_page import SecureAreaPage def test_login_success(driver): login = LoginPage(driver) login.load() # Credentials are provided by the demo site login.login_as("tomsmith", "SuperSecretPassword!") secure = SecureAreaPage(driver) assert secure.header_text() == "Secure Area" assert "You logged into a secure area!" in secure.flash_message() def test_login_failure(driver): login = LoginPage(driver) login.load() login.login_as("tomsmith", "wrong-password") assert "Your password is invalid!" in login.flash_message()

Run it:

pytest -q

If you want to see the browser:

HEADLESS=0 pytest -q

Practical Anti-Flake Tips You Can Apply Immediately

  • Prefer stable selectors: In your own apps, add data-testid or data-test attributes. CSS like [data-testid="login-submit"] is far more stable than “the third button in the form”.
  • Wait for state, not time: Use conditions like “element is visible/clickable” or “URL contains /dashboard”, not sleep(2).
  • Make assertions specific: Verify a clear outcome (header text, success toast, URL change). Vague assertions (“page contains ‘Welcome’”) can hide real failures.
  • Keep page objects boring: Put selectors + simple actions there. Complex branching logic belongs in tests or helper functions.
  • Capture evidence on failure: In real projects, add a pytest hook to save a screenshot/HTML when a test fails.

Bonus: Screenshot on Failure (Tiny, Useful Upgrade)

Drop this into conftest.py to save a screenshot whenever a test fails (super helpful in CI):

import pathlib @pytest.hookimpl(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: out = pathlib.Path("artifacts") out.mkdir(exist_ok=True) png = out / f"{item.name}.png" driver.save_screenshot(str(png))

Now failed runs leave behind artifacts/*.png you can inspect.

Where to Go Next

With this foundation, you can grow into real-world E2E coverage:

  • Parameterize tests for multiple roles/users with @pytest.mark.parametrize
  • Add API setup/teardown for deterministic test data (create user/order before UI checks)
  • Run tests in parallel with pytest-xdist once they’re stable
  • Use a Selenium Grid (or cloud providers) for cross-browser coverage when needed

If you adopt just two habits—Page Objects and explicit waits—you’ll eliminate most “random Selenium failures” and ship UI automation that your team actually trusts.


Leave a Reply

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