Reliable Selenium Automation in Python: Page Objects + Explicit Waits (That Don’t Flake)
UI automation is powerful—and also infamous for flaky tests. The good news: most flakiness comes from a few predictable issues (timing, brittle selectors, and poor structure). In this hands-on guide, you’ll build a small Selenium test suite in Python that’s:
- Readable (Page Object pattern)
- Stable (explicit waits instead of
time.sleep) - CI-friendly (headless mode + deterministic setup)
We’ll automate a simple login flow (works with any web app that has email/password fields and a post-login element). You can adapt the selectors to your project in minutes.
1) Setup: Create a Project and Install Dependencies
Create a new folder and a virtual environment:
mkdir selenium-stable-tests cd selenium-stable-tests python -m venv .venv # macOS/Linux: source .venv/bin/activate # Windows (PowerShell): # .\.venv\Scripts\Activate.ps1
Install Selenium and pytest. We’ll also use webdriver-manager so you don’t have to manually download browser drivers.
pip install selenium pytest webdriver-manager
Tip: If your company locks down driver downloads, you may need a preinstalled driver (e.g., chromedriver) and skip webdriver-manager. The rest of the article stays the same.
2) Recommended Folder Structure
Keep your tests maintainable by separating page logic from test logic:
selenium-stable-tests/ pages/ __init__.py base_page.py login_page.py dashboard_page.py tests/ __init__.py test_login.py conftest.py pytest.ini
pages/= Page Objects (selectors + actions)tests/= readable test scenariosconftest.py= shared fixtures (driver setup)
3) Driver Fixture: Headless by Default
Create conftest.py. This fixture sets up Chrome in headless mode and tears it down after each test. It also adds a few options that reduce “works on my machine” problems.
from __future__ import annotations import os import pytest from selenium import webdriver from selenium.webdriver.chrome.options import Options from webdriver_manager.chrome import ChromeDriverManager from selenium.webdriver.chrome.service import Service def _chrome_options() -> Options: options = Options() headless = os.getenv("HEADLESS", "1") == "1" if headless: # Chrome's modern headless mode options.add_argument("--headless=new") # Stability / CI flags options.add_argument("--window-size=1365,900") options.add_argument("--disable-dev-shm-usage") options.add_argument("--no-sandbox") # Optional: reduce automation banners (won't hide Selenium completely) options.add_argument("--disable-infobars") options.add_argument("--disable-gpu") return options @pytest.fixture() def driver(): service = Service(ChromeDriverManager().install()) drv = webdriver.Chrome(service=service, options=_chrome_options()) drv.implicitly_wait(0) # Important: prefer explicit waits over implicit waits yield drv drv.quit()
Why disable implicit waits? Mixing implicit and explicit waits can cause confusing timing behavior. If you want reliability, pick explicit waits and stick with them.
4) Build a Base Page With Explicit Wait Helpers
Create pages/base_page.py. This is where we centralize waiting and interactions. The goal: tests call page methods like login(), not low-level Selenium code.
from __future__ import annotations from selenium.webdriver.remote.webdriver import WebDriver from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC class BasePage: def __init__(self, driver: WebDriver, timeout: int = 10): self.driver = driver self.wait = WebDriverWait(driver, timeout) def open(self, url: str) -> None: self.driver.get(url) def wait_visible(self, locator): return self.wait.until(EC.visibility_of_element_located(locator)) def wait_clickable(self, locator): return self.wait.until(EC.element_to_be_clickable(locator)) def click(self, locator) -> None: self.wait_clickable(locator).click() def type(self, locator, text: str, clear: bool = True) -> None: el = self.wait_visible(locator) if clear: el.clear() el.send_keys(text) def text_of(self, locator) -> str: return self.wait_visible(locator).text
Why explicit waits? Because UI timing is asynchronous: animations, API calls, slow renders. Waiting for a condition (visible/clickable) is much more robust than sleeping “for 2 seconds and hoping.”
5) Create Page Objects (Selectors + User Actions)
Create pages/login_page.py. Replace the locators with ones from your app (use browser DevTools → inspect element).
from __future__ import annotations from selenium.webdriver.common.by import By from .base_page import BasePage class LoginPage(BasePage): # Example locators (adjust to your app): EMAIL = (By.CSS_SELECTOR, "input[name='email']") PASSWORD = (By.CSS_SELECTOR, "input[name='password']") SUBMIT = (By.CSS_SELECTOR, "button[type='submit']") ERROR = (By.CSS_SELECTOR, "[data-test='login-error']") def login(self, email: str, password: str) -> None: self.type(self.EMAIL, email) self.type(self.PASSWORD, password) self.click(self.SUBMIT) def error_message(self) -> str: return self.text_of(self.ERROR)
Create pages/dashboard_page.py. This represents the post-login state. The trick is to identify one element that reliably proves login succeeded (e.g., a user menu, a “Dashboard” heading, or a logout button).
from __future__ import annotations from selenium.webdriver.common.by import By from .base_page import BasePage class DashboardPage(BasePage): # Pick an element that's only visible after login USER_MENU = (By.CSS_SELECTOR, "[data-test='user-menu']") def is_loaded(self) -> bool: self.wait_visible(self.USER_MENU) return True
6) Write Tests That Read Like Scenarios
Create tests/test_login.py. These tests are “hands-on”: they call user-level actions and assert user-level outcomes.
import os from pages.login_page import LoginPage from pages.dashboard_page import DashboardPage BASE_URL = os.getenv("BASE_URL", "http://localhost:3000") def test_login_success(driver): login = LoginPage(driver) login.open(f"{BASE_URL}/login") login.login("[email protected]", "correct-horse-battery-staple") dashboard = DashboardPage(driver) assert dashboard.is_loaded() is True def test_login_invalid_password_shows_error(driver): login = LoginPage(driver) login.open(f"{BASE_URL}/login") login.login("[email protected]", "wrong-password") # Assert an error users can see assert "invalid" in login.error_message().lower()
How to run:
pytest -q
If your app isn’t running locally, point BASE_URL to a staging environment:
BASE_URL="https://staging.example.com" pytest -q
7) Make Selectors Less Brittle (One Small Rule)
Most brittle tests die because selectors are tied to layout or styling (deep CSS paths, random class names, or nth-child chains). A practical rule:
- Prefer
data-testordata-testidattributes (stable, explicit intent). - Fallback: semantic attributes like
name,aria-label, or button text.
Example HTML your frontend can add:
<button data-test="user-menu">Account</button>
Then your locator becomes durable:
USER_MENU = (By.CSS_SELECTOR, "[data-test='user-menu']")
8) Common Flake Killers (Practical Checklist)
- Never use
time.sleepas “waiting.” Use explicit waits for visible/clickable/text present. - Wait for the right condition: not “element exists,” but “element is visible and ready.”
- Avoid asserting immediately after navigation—wait for a post-action anchor element (like
DashboardPage.USER_MENU). - Use a consistent viewport (
--window-size) so responsive layouts don’t change element positions. - If clicks fail due to overlays/spinners, wait for the overlay to disappear (e.g.,
EC.invisibility_of_element_located).
Example: wait for a loading spinner to vanish (add to BasePage if needed):
from selenium.webdriver.support import expected_conditions as EC def wait_gone(self, locator): return self.wait.until(EC.invisibility_of_element_located(locator))
9) Add a Simple pytest.ini for Cleaner Output
Create pytest.ini:
[pytest] addopts = -ra testpaths = tests
10) Where to Go Next
Once you have stable basics, the next upgrades that give the biggest ROI are:
- Screenshots on failure (save images when a test fails)
- Parallel runs (split tests across workers with
pytest-xdist) - CI integration (run headless in GitHub Actions/GitLab CI with a predictable Chrome setup)
- Visual regression (only if you truly need UI pixel checks—otherwise keep it functional)
If you keep Page Objects small, use explicit waits consistently, and enforce stable selectors (like data-test), Selenium becomes a reliable safety net instead of a flaky time sink.
Leave a Reply