Selenium Automation for Web Apps: Reliable UI Tests with Explicit Waits, Page Objects, and pytest

Selenium Automation for Web Apps: Reliable UI Tests with Explicit Waits, Page Objects, and pytest

UI automation is one of the fastest ways to catch “it works locally” bugs that only show up in real browsers: broken selectors, missing buttons, wrong redirects, and client-side validation regressions. But Selenium tests can also become flaky if you rely on time.sleep() or brittle selectors.

This hands-on guide shows a practical baseline for junior/mid developers: a clean project setup, stable waits, a Page Object pattern, and a couple of tests you can run in CI later.

What you’ll build

  • A minimal Selenium + pytest test project
  • A reusable WebDriver fixture (headless by default)
  • Stable interactions using explicit waits
  • A Page Object for a login page
  • Two working tests: “login succeeds” and “error shown on bad password”

Project setup (Python)

Create a folder (for example ui-tests/) and install dependencies:

python -m venv .venv # macOS/Linux source .venv/bin/activate # Windows (PowerShell) # .venv\Scripts\Activate.ps1 pip install selenium pytest webdriver-manager

Suggested structure:

ui-tests/ pages/ login_page.py tests/ test_login.py conftest.py pytest.ini

Add a pytest.ini so test output is readable and you can mark slow tests later:

[pytest] addopts = -q testpaths = tests

Driver setup: headless Chrome + good defaults

The most common causes of flaky tests are inconsistent viewport size, missing waits, and leftover state between tests. Start with a clean driver fixture that:

  • Runs headless by default (faster, CI-friendly)
  • Uses a fixed window size (stable responsive layouts)
  • Sets a small implicit wait to 0 (so you rely on explicit waits)
  • Takes screenshots on failure (optional but very helpful)

Create conftest.py:

import os import pathlib 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 ARTIFACTS_DIR = pathlib.Path("artifacts") ARTIFACTS_DIR.mkdir(exist_ok=True) def _chrome_options() -> Options: opts = Options() # Headless by default (set HEADLESS=0 to see the browser) headless = os.getenv("HEADLESS", "1") == "1" if headless: # "--headless=new" works on newer Chromes 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 opts @pytest.fixture def driver(request): service = Service(ChromeDriverManager().install()) drv = webdriver.Chrome(service=service, options=_chrome_options()) # Prefer explicit waits; keep implicit wait off to avoid hidden timing issues drv.implicitly_wait(0) yield drv # If a test fails, try to save a screenshot if hasattr(request.node, "rep_call") and request.node.rep_call.failed: name = request.node.name.replace("/", "_") screenshot_path = ARTIFACTS_DIR / f"{name}.png" try: drv.save_screenshot(str(screenshot_path)) print(f"\nSaved screenshot: {screenshot_path}") except Exception: pass drv.quit() # Hook to know if the test failed so the fixture can screenshot @pytest.hookimpl(hookwrapper=True, tryfirst=True) def pytest_runtest_makereport(item, call): outcome = yield rep = outcome.get_result() setattr(item, "rep_" + rep.when, rep)

Run tests normally:

pytest

If you want to see the browser:

HEADLESS=0 pytest -s

Stop using sleep(): explicit waits you can trust

time.sleep(2) is tempting, but it’s guessing. Instead, wait for a real condition: an element is visible, clickable, or the URL changes. Selenium provides WebDriverWait with expected conditions.

Common wait patterns:

  • visibility_of_element_located for text inputs, labels, alerts
  • element_to_be_clickable for buttons/links
  • url_contains or url_to_be for navigation
  • presence_of_all_elements_located for lists/tables

Page Objects: keep selectors and flows out of tests

When tests directly reference selectors everywhere, small UI changes become a painful mass edit. A Page Object keeps selectors + interactions in one place, so tests read like user steps.

Create pages/login_page.py:

from dataclasses import dataclass from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC @dataclass class LoginPage: driver: any base_url: str # Prefer data-testid if your app supports it: # e.g. <input data-testid="email" /> EMAIL = (By.CSS_SELECTOR, '[data-testid="email"], input[name="email"], input[type="email"]') PASSWORD = (By.CSS_SELECTOR, '[data-testid="password"], input[name="password"], input[type="password"]') SUBMIT = (By.CSS_SELECTOR, '[data-testid="login-submit"], button[type="submit"]') ERROR = (By.CSS_SELECTOR, '[data-testid="login-error"], .alert, .error, [role="alert"]') def open(self): self.driver.get(f"{self.base_url}/login") return self def wait_visible(self, locator, timeout=10): return WebDriverWait(self.driver, timeout).until( EC.visibility_of_element_located(locator) ) def wait_clickable(self, locator, timeout=10): return WebDriverWait(self.driver, timeout).until( EC.element_to_be_clickable(locator) ) def login(self, email: str, password: str): email_el = self.wait_visible(self.EMAIL) email_el.clear() email_el.send_keys(email) pass_el = self.wait_visible(self.PASSWORD) pass_el.clear() pass_el.send_keys(password) self.wait_clickable(self.SUBMIT).click() return self def error_text(self) -> str: # Wait a bit for an alert to appear el = self.wait_visible(self.ERROR, timeout=5) return el.text.strip()

Tip: If you control the app, add data-testid attributes. They’re stable across CSS refactors and copy changes. Example:

<input data-testid="email" name="email" /> <div data-testid="login-error" role="alert">Invalid credentials</div>

Writing tests with pytest

Now write tests that read like user behavior. Create tests/test_login.py:

import os from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from pages.login_page import LoginPage BASE_URL = os.getenv("BASE_URL", "http://localhost:3000") def test_login_success_redirects(driver): page = LoginPage(driver, BASE_URL).open() # Use known test credentials from your dev environment page.login("[email protected]", "correct-password") # Assert: redirect happened (adjust to your app) WebDriverWait(driver, 10).until(EC.url_contains("/dashboard")) assert "/dashboard" in driver.current_url def test_login_bad_password_shows_error(driver): page = LoginPage(driver, BASE_URL).open() page.login("[email protected]", "wrong-password") msg = page.error_text().lower() assert "invalid" in msg or "incorrect" in msg or "wrong" in msg

Run against your app (set BASE_URL if needed):

BASE_URL=http://localhost:3000 pytest

Practical stability checklist (the stuff that saves hours)

  • Prefer explicit waits. If you see intermittent failures, add a wait for the exact state you need (button clickable, alert visible, URL changed).

  • Use stable locators. Best: data-testid. Good: semantic attributes like name. Avoid: deep CSS chains like div > div:nth-child(3) > button.

  • Keep tests independent. Each test should set up its own state (log in, create record, etc.) or reset via API/db hooks.

  • Take screenshots on failure. The fixture above saves images to artifacts/.

  • Fix the viewport. Without a consistent size, responsive layouts can hide elements and break clicks.

  • Don’t mix implicit + explicit waits. Implicit waits can silently inflate timings and make debugging harder. Keep implicit at 0 and be intentional.

Bonus: waiting for a table row or toast message

Two patterns you’ll use a lot:

Wait for a list/table to have at least one row:

from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait rows = WebDriverWait(driver, 10).until( lambda d: d.find_elements(By.CSS_SELECTOR, "table tbody tr") ) assert len(rows) > 0

Wait for a toast notification to appear and disappear:

from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC TOAST = (By.CSS_SELECTOR, '[data-testid="toast"], .toast, [role="status"]') toast = WebDriverWait(driver, 10).until(EC.visibility_of_element_located(TOAST)) assert "saved" in toast.text.lower() WebDriverWait(driver, 10).until(EC.invisibility_of_element_located(TOAST))

Where to go next

Once this baseline is working, you can expand safely:

  • Add more Page Objects (DashboardPage, SettingsPage, etc.) and keep tests short.

  • Run tests in CI (GitHub Actions, GitLab CI) using headless mode.

  • Parallelize with pytest-xdist when the suite grows.

  • Use a test database or API helpers to set up state faster than clicking through the UI.

If you implement just two habits—explicit waits and stable selectors—your Selenium suite will be dramatically more reliable, and you’ll spend your time catching real bugs instead of chasing flakes.


Leave a Reply

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