Selenium Automation That Doesn’t Flake: A Hands-On Starter Kit (Python + Pytest)

Selenium Automation That Doesn’t Flake: A Hands-On Starter Kit (Python + Pytest)

Selenium is often a junior developer’s first taste of end-to-end (E2E) testing—and also their first taste of “it passed locally but failed in CI.” The good news: most flakiness comes from a few predictable causes (timing, unstable selectors, and unclear test structure). In this article, you’ll build a small but solid Selenium setup using pytest, explicit waits, and the Page Object Model (POM). The examples are copy-pasteable and designed to be extended to real apps.

What you’ll build

  • A reusable Selenium test harness with pytest fixtures
  • Stable element interactions using explicit waits (no sleep())
  • A Page Object for a login flow
  • Common “flaky test” fixes: selectors, waits, screenshots on failure

Install dependencies

You’ll need Python 3.10+ (older may still work) and a browser driver. Selenium 4+ can often manage drivers automatically, but teams frequently pin versions in CI. Start simple:

python -m venv .venv # Windows: .venv\Scripts\activate source .venv/bin/activate pip install -U pip pip install selenium pytest

Quick check:

python -c "import selenium; print(selenium.__version__)"

Project structure (keep it boring)

This structure scales cleanly without becoming a framework:

e2e/ pages/ login_page.py dashboard_page.py tests/ test_login.py conftest.py pytest.ini

Base configuration: pytest + WebDriver fixture

Create pytest.ini so test output and discovery are consistent:

[pytest] testpaths = e2e/tests addopts = -q

Now create e2e/conftest.py. This gives you a browser per test (or per session if you decide to optimize later), with headless mode optional.

import os import pytest from selenium import webdriver from selenium.webdriver.chrome.options import Options as ChromeOptions @pytest.fixture def base_url(): # Change this to your local dev URL, staging URL, etc. return os.getenv("BASE_URL", "https://the-internet.herokuapp.com") @pytest.fixture def driver(): headless = os.getenv("HEADLESS", "1") == "1" options = ChromeOptions() if headless: options.add_argument("--headless=new") options.add_argument("--window-size=1280,900") options.add_argument("--disable-gpu") options.add_argument("--no-sandbox") driver = webdriver.Chrome(options=options) driver.set_page_load_timeout(20) yield driver driver.quit()

Why this helps: You now have one place to change browser settings, timeouts, and headless behavior. When tests fail, it’s not because you ran Chrome differently in each file.

Stop using time.sleep(): use explicit waits

Most flaky Selenium tests are “too fast” for the UI. The right fix is explicit waits: wait for a specific condition (element visible, clickable, URL changed), not an arbitrary delay.

Create a small helper in your page objects using Selenium’s WebDriverWait and expected conditions.

from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def wait_for(driver, condition, timeout=10): return WebDriverWait(driver, timeout).until(condition)

You’ll use this pattern inside your pages so tests remain readable.

Page Object Model: one page, one class

POM keeps “how to click/type/find elements” out of the test itself. Tests describe intent; pages describe mechanics. Create e2e/pages/login_page.py:

from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC class LoginPage: # Prefer stable selectors: # - data-testid (best, add in your app if possible) # - id/name (usually stable) # - CSS classes are often volatile USERNAME = (By.ID, "username") PASSWORD = (By.ID, "password") SUBMIT = (By.CSS_SELECTOR, "button[type='submit']") FLASH = (By.ID, "flash") def __init__(self, driver, base_url): self.driver = driver self.base_url = base_url def open(self): self.driver.get(f"{self.base_url}/login") return self def login_as(self, username, password): wait = WebDriverWait(self.driver, 10) user = wait.until(EC.visibility_of_element_located(self.USERNAME)) user.clear() user.send_keys(username) pwd = wait.until(EC.visibility_of_element_located(self.PASSWORD)) pwd.clear() pwd.send_keys(password) btn = wait.until(EC.element_to_be_clickable(self.SUBMIT)) btn.click() def flash_message(self): wait = WebDriverWait(self.driver, 10) el = wait.until(EC.visibility_of_element_located(self.FLASH)) return el.text

This example targets a public demo site (great for practice). In your real app, push your team to add data-testid attributes—this is the single biggest “Selenium stability” upgrade you can make.

Your first real test: login success + failure

Create e2e/tests/test_login.py:

from e2e.pages.login_page import LoginPage from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def test_login_success(driver, base_url): page = LoginPage(driver, base_url).open() page.login_as("tomsmith", "SuperSecretPassword!") # Assert something meaningful (URL change, element visible, etc.) WebDriverWait(driver, 10).until(EC.url_contains("/secure")) assert "You logged into a secure area" in driver.page_source def test_login_failure(driver, base_url): page = LoginPage(driver, base_url).open() page.login_as("wrong", "credentials") msg = page.flash_message() assert "Your username is invalid" in msg

What to notice: the test itself reads like a user story. All the “how” lives in the page class.

Make failures actionable: screenshot + HTML dump on failure

When an E2E test fails, you want evidence—not guesses. Add a pytest hook in e2e/conftest.py that saves artifacts when a test fails.

import pathlib import pytest ARTIFACTS_DIR = pathlib.Path("artifacts") @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): outcome = yield report = outcome.get_result() if report.when == "call" and report.failed: driver = item.funcargs.get("driver") if driver: ARTIFACTS_DIR.mkdir(exist_ok=True) name = report.nodeid.replace("/", "_").replace("::", "__") screenshot_path = ARTIFACTS_DIR / f"{name}.png" html_path = ARTIFACTS_DIR / f"{name}.html" driver.save_screenshot(str(screenshot_path)) html_path.write_text(driver.page_source, encoding="utf-8")

Now when something breaks, you can open the screenshot and HTML to see what the browser actually saw.

Selector strategy that prevents pain

Selectors are the second most common flake source. Here’s a practical priority order:

  • Best: data-testid (or data-test) attributes you control
  • Great: id, name (stable when designed intentionally)
  • Okay: semantic selectors (e.g., button[type="submit"])
  • Avoid: long CSS chains and XPath based on layout
  • Avoid: brittle text matches unless the text is truly stable

If you take one habit from this article: ask your team to add data-testid to critical UI elements (login inputs, submit buttons, navigation, key tables). Your tests become resilient to CSS refactors.

Common flakiness fixes (quick checklist)

  • Replace time.sleep() with explicit waits: visibility_of_element_located, element_to_be_clickable, url_contains.
  • Wait for the right thing: not “element exists,” but “element clickable” if you’re about to click.
  • Clear inputs before typing to avoid leftover state: el.clear().
  • Keep tests independent: don’t rely on previous test state.
  • Collect artifacts on failure (screenshots + HTML) so you can debug fast.
  • Stabilize selectors with data-testid.

Run it

From the project root:

pytest

To run headed (non-headless) for debugging:

HEADLESS=0 pytest -q

Where to go next

Once this foundation feels comfortable, add:

  • A BasePage class (shared wait helpers, common navigation)
  • Test data factories (generate users, orders, etc.) via API calls or fixtures
  • A “smoke suite” tag for a fast subset of E2E tests (good for PR checks)

If you want, tell me your stack (React/Vue/Angular, auth method, and whether you can add data-testid) and I’ll tailor the Page Objects and selector strategy to your app.


Leave a Reply

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