End-to-End Browser Automation with Selenium (the Maintainable Way)
Selenium is still one of the most practical tools for “real browser” automation: testing flows that need JavaScript, working with tricky UI widgets, and catching regressions that unit tests can’t see. The catch: many Selenium scripts turn into brittle click-fests that fail randomly and are painful to maintain.
In this hands-on guide, you’ll build a small but realistic Selenium setup that junior/mid developers can keep stable over time: a clean project structure, reliable waits, reusable page objects, good locators, and debugging artifacts (screenshots + HTML dumps). You’ll end with a working test that logs in and verifies a dashboard element.
What you’ll build
- A Python Selenium project with a repeatable structure
- A
BasePagewith safe, reusable actions - A
LoginPageandDashboardPageusing stable locators - A runnable test that uses explicit waits (no
time.sleep) - Failure debugging: screenshot + page source saved automatically
Setup: dependencies and folder structure
Install Selenium and a test runner. We’ll use pytest because it’s simple and popular.
python -m venv .venv # macOS/Linux: source .venv/bin/activate # Windows: # .venv\Scripts\activate pip install selenium pytest
Create this structure:
selenium-demo/ pages/ __init__.py base_page.py login_page.py dashboard_page.py tests/ test_login_flow.py utils/ __init__.py artifacts.py conftest.py
We’ll assume you’re testing a web app with:
- Login page:
/login - Dashboard:
/dashboard - A visible “welcome” element like
[data-testid="welcome-banner"]
If your app doesn’t have data-testid attributes, you can still use CSS selectors, but adding test IDs is one of the best stability upgrades you can make.
Golden rules for stable Selenium
- Prefer explicit waits via
WebDriverWaitand expected conditions. Avoidtime.sleepunless you’re debugging. - Use stable locators:
data-testid> unique IDs > robust CSS. Avoid brittle XPath tied to layout. - One responsibility per method: “type username”, “click login”, “wait for dashboard”. Small methods make failures easier to debug.
- Capture artifacts on failure: screenshot + HTML source save hours of guesswork, especially in CI.
- Don’t fight the browser: wait for elements to be clickable, scroll into view when needed, and handle overlays gracefully.
Create a WebDriver fixture (Chrome, headless-friendly)
In conftest.py, define a driver fixture. This manages browser lifecycle for each test.
from pathlib import Path import pytest from selenium import webdriver from selenium.webdriver.chrome.options import Options @pytest.fixture def driver(): options = Options() # Headless is great for CI. Comment out for local debugging. 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") driver = webdriver.Chrome(options=options) driver.set_page_load_timeout(20) yield driver driver.quit()
Tip: If ChromeDriver isn’t found automatically, install a driver manager (or ensure ChromeDriver is on PATH). Many modern Selenium setups can also use Selenium Manager automatically depending on your environment.
Add failure artifacts (screenshot + HTML)
When a test fails, you want evidence. Create utils/artifacts.py:
from pathlib import Path from datetime import datetime def save_artifacts(driver, name_prefix: str = "failure", out_dir: str = "artifacts") -> dict: Path(out_dir).mkdir(parents=True, exist_ok=True) ts = datetime.utcnow().strftime("%Y%m%d-%H%M%S") base = Path(out_dir) / f"{name_prefix}-{ts}" screenshot_path = f"{base}.png" html_path = f"{base}.html" driver.save_screenshot(screenshot_path) with open(html_path, "w", encoding="utf-8") as f: f.write(driver.page_source) return {"screenshot": screenshot_path, "html": html_path}
Now wire it into pytest with a hook so failures automatically capture artifacts. Add this to conftest.py (below the fixture):
import pytest from utils.artifacts import save_artifacts @pytest.hookimpl(hookwrapper=True, tryfirst=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: artifacts = save_artifacts(driver, name_prefix=item.name) # Attach paths to the report for easy printing/logging rep.artifacts = artifacts
This hook stores artifact paths on the report object. In CI logs, you can print them or upload the artifact folder.
Build a BasePage with safe waits and actions
The biggest Selenium quality jump comes from centralizing waits and interactions. Create pages/base_page.py:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.common.exceptions import TimeoutException class BasePage: def __init__(self, driver, base_url: str): self.driver = driver self.base_url = base_url.rstrip("/") self.wait = WebDriverWait(driver, 10) def open(self, path: str): url = f"{self.base_url}/{path.lstrip('/')}" self.driver.get(url) return self 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): el = self.wait_clickable(locator) el.click() return el def type(self, locator, text: str, clear: bool = True): el = self.wait_visible(locator) if clear: el.clear() el.send_keys(text) return el def is_visible(self, locator) -> bool: try: self.wait_visible(locator) return True except TimeoutException: return False
Why this matters:
- Every click waits until the element is clickable (reduces “element not interactable” issues).
- Typing waits until the field is visible.
- Your tests read like steps, not low-level browser plumbing.
Create Page Objects (Login + Dashboard)
Page Objects keep selectors and UI behavior in one place. If the UI changes, you update one file—not 20 tests.
Create pages/login_page.py:
from selenium.webdriver.common.by import By from pages.base_page import BasePage class LoginPage(BasePage): 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"]') def load(self): return self.open("/login") def login_as(self, username: str, password: str): self.type(self.USERNAME, username) self.type(self.PASSWORD, password) self.click(self.SUBMIT) return self
Create pages/dashboard_page.py:
from selenium.webdriver.common.by import By from pages.base_page import BasePage class DashboardPage(BasePage): WELCOME = (By.CSS_SELECTOR, '[data-testid="welcome-banner"]') def load(self): return self.open("/dashboard") def is_loaded(self) -> bool: return self.is_visible(self.WELCOME)
Notice: no sleeps, no random retries, no XPath acrobatics.
Write the test: readable, reliable, and fast
Create tests/test_login_flow.py:
from pages.login_page import LoginPage from pages.dashboard_page import DashboardPage BASE_URL = "http://localhost:3000" def test_login_redirects_to_dashboard(driver): login = LoginPage(driver, BASE_URL).load() login.login_as("[email protected]", "password123") dashboard = DashboardPage(driver, BASE_URL) assert dashboard.is_loaded(), "Dashboard did not load (welcome banner not visible)"
Run it:
pytest -q
If it fails, check the artifacts/ folder for a screenshot and HTML dump of the failing state.
Make locators more robust (practical tips)
If your app doesn’t use data-testid, you can still make locators stable. Aim for selectors that reflect meaning, not layout.
- Prefer a unique attribute:
[name="email"],#login-button,[aria-label="Search"] - Avoid “positional” selectors like
div > div:nth-child(2) - Avoid text-based XPath for changing copy (unless your UI text is guaranteed stable)
- If you control the frontend, add
data-testidspecifically for automation and keep them stable across refactors
Handle common flakiness: overlays, slow SPA loads, and stale elements
Here are quick patterns you can add when your app gets more complex:
- SPA route changes: wait for a known element on the next page (like the
WELCOMEbanner) rather than waiting for URL alone. - Loading spinners: wait for a spinner to disappear before clicking.
- Stale element references: don’t store element objects long-term; re-find when interacting.
Example: wait for a spinner to disappear before clicking:
from selenium.webdriver.common.by import By from selenium.webdriver.support import expected_conditions as EC SPINNER = (By.CSS_SELECTOR, '[data-testid="loading"]') def wait_spinner_gone(self): self.wait.until(EC.invisibility_of_element_located(SPINNER))
Take it to CI (quick checklist)
- Run headless in CI:
--headless=new - Set a window size: many responsive UIs change layout at small widths
- Upload
artifacts/when tests fail (screenshots + HTML are gold) - Keep tests focused: one flow per test, minimal branching logic
- Use a test database or seeded data so accounts and state are predictable
Wrap-up: the maintainable Selenium baseline
You now have a solid Selenium foundation that avoids the most common pitfalls:
- Explicit waits instead of sleeps
- Stable selectors and Page Objects
- Centralized, reusable browser actions
- Automatic debugging artifacts on failure
From here, you can add more page objects, build helper fixtures for authenticated sessions, and gradually expand coverage without turning your suite into a flaky mess. If you want, I can adapt the locators and flow to your specific app’s pages and HTML (even just a snippet of the login form is enough).
Leave a Reply