Selenium Automation in Practice: Build Reliable UI Smoke Tests with Page Objects + Explicit Waits
UI tests have a reputation: slow, flaky, and painful to maintain. The good news is that most flakiness comes from a few avoidable mistakes—like clicking elements before the page is ready, relying on brittle selectors, or mixing “how to find things” with “what the test is trying to prove.”
In this hands-on guide, you’ll build a small but robust Selenium test setup in Python that junior/mid developers can maintain. We’ll focus on:
- Using
pytestas a test runner - Keeping tests readable with the Page Object pattern
- Replacing
time.sleep()with explicit waits - Capturing screenshots automatically on failures
- Running headless locally and in CI later (same code)
All code examples are “working style” and can be copied into a real project.
Project Setup
Create a virtual environment and install dependencies:
python -m venv .venv # macOS/Linux: source .venv/bin/activate # Windows: # .venv\Scripts\activate pip install -U pip pip install selenium pytest
Driver note: Modern Selenium can often manage browser drivers automatically (via Selenium Manager). If your environment can’t download drivers, you may need to install Chrome/Firefox drivers manually and ensure they’re on your PATH.
Suggested folder structure:
ui-tests/ pages/ base_page.py login_page.py dashboard_page.py tests/ test_login_smoke.py conftest.py pytest.ini
Why Explicit Waits Beat Sleeps
If you ever wrote:
import time time.sleep(2) driver.find_element(...).click()
…you’ve already met “flakiness.” Sleeps are guesses. Explicit waits are conditions. You want “wait until the button is clickable” instead of “wait 2 seconds and hope.”
Selenium’s WebDriverWait + expected conditions solve 80% of UI test instability.
Step 1: Create a Base Page with Safe Helpers
All pages will share common actions: visiting URLs, clicking, typing, and waiting. Put those in a reusable base class so tests stay clean.
# pages/base_page.py from __future__ import annotations from selenium.webdriver.remote.webdriver import WebDriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC class BasePage: def __init__(self, driver: WebDriver, base_url: str, timeout: int = 10): self.driver = driver self.base_url = base_url.rstrip("/") self.wait = WebDriverWait(driver, timeout) def open(self, path: str) -> None: url = f"{self.base_url}/{path.lstrip('/')}" self.driver.get(url) def wait_visible(self, by: By, value: str): return self.wait.until(EC.visibility_of_element_located((by, value))) def wait_clickable(self, by: By, value: str): return self.wait.until(EC.element_to_be_clickable((by, value))) def click(self, by: By, value: str) -> None: self.wait_clickable(by, value).click() def type(self, by: By, value: str, text: str, clear: bool = True) -> None: el = self.wait_visible(by, value) if clear: el.clear() el.send_keys(text) def text_of(self, by: By, value: str) -> str: return self.wait_visible(by, value).text
Tip: Prefer stable selectors: data-testid attributes are a lifesaver. If you own the app, add them. If you don’t, prefer unique IDs over CSS chains like div > div > button:nth-child(2).
Step 2: Implement Page Objects (Login + Dashboard)
A Page Object wraps “how to interact with a page” behind methods like login(). This keeps tests expressive and reduces duplication.
# pages/login_page.py from selenium.webdriver.common.by import By from pages.base_page import BasePage class LoginPage(BasePage): # Use data-testid in your app if possible: EMAIL = (By.CSS_SELECTOR, '[data-testid="email"]') PASSWORD = (By.CSS_SELECTOR, '[data-testid="password"]') SUBMIT = (By.CSS_SELECTOR, '[data-testid="login-submit"]') ERROR = (By.CSS_SELECTOR, '[data-testid="login-error"]') def open_page(self) -> None: self.open("/login") def login(self, email: str, password: str) -> None: self.type(*self.EMAIL, text=email) self.type(*self.PASSWORD, text=password) self.click(*self.SUBMIT) def error_message(self) -> str: return self.text_of(*self.ERROR)
# pages/dashboard_page.py from selenium.webdriver.common.by import By from pages.base_page import BasePage class DashboardPage(BasePage): HEADER = (By.CSS_SELECTOR, '[data-testid="dashboard-header"]') USER_MENU = (By.CSS_SELECTOR, '[data-testid="user-menu"]') def is_loaded(self) -> bool: self.wait_visible(*self.HEADER) return True def open_user_menu(self) -> None: self.click(*self.USER_MENU)
Step 3: Configure WebDriver Once with pytest Fixtures
Fixtures give you a consistent browser setup across tests. You’ll also add headless support via an environment variable.
# conftest.py import os import pathlib import pytest from selenium import webdriver from selenium.webdriver.chrome.options import Options as ChromeOptions def _chrome_driver(): options = ChromeOptions() options.add_argument("--window-size=1400,900") # Enable headless mode when HEADLESS=1 if os.getenv("HEADLESS") == "1": # Selenium/Chrome headless mode options.add_argument("--headless=new") # Helpful defaults for CI/container-like environments options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") return webdriver.Chrome(options=options) @pytest.fixture def base_url(): # Point this to your app under test return os.getenv("BASE_URL", "http://localhost:3000") @pytest.fixture def driver(request): drv = _chrome_driver() drv.implicitly_wait(0) # explicit waits only (more predictable) yield drv drv.quit() @pytest.hookimpl(tryfirst=True, hookwrapper=True) def pytest_runtest_makereport(item, call): # Attach test report info to the item so the driver fixture can access it outcome = yield rep = outcome.get_result() setattr(item, "rep_" + rep.when, rep) @pytest.fixture(autouse=True) def screenshot_on_failure(request, driver): yield # After test, if it failed, take a screenshot if hasattr(request.node, "rep_call") and request.node.rep_call.failed: out_dir = pathlib.Path("artifacts") out_dir.mkdir(exist_ok=True) filename = out_dir / f"{request.node.name}.png" driver.save_screenshot(str(filename))
This automatically saves screenshots into artifacts/ when a test fails—huge time-saver when debugging CI failures.
Step 4: Write a Smoke Test That Doesn’t Flake
Now you’ll write a “smoke” test for a successful login and a negative test for invalid credentials. These tests read like user flows, not like DOM scripts.
# tests/test_login_smoke.py import os import pytest from pages.login_page import LoginPage from pages.dashboard_page import DashboardPage @pytest.mark.smoke def test_login_success(driver, base_url): email = os.getenv("TEST_USER_EMAIL", "[email protected]") password = os.getenv("TEST_USER_PASSWORD", "correct-horse-battery-staple") login = LoginPage(driver, base_url) dashboard = DashboardPage(driver, base_url) login.open_page() login.login(email=email, password=password) assert dashboard.is_loaded() @pytest.mark.smoke def test_login_invalid_shows_error(driver, base_url): login = LoginPage(driver, base_url) login.open_page() login.login(email="[email protected]", password="wrong-password") # Assert on a stable UI signal (not on timing) assert "invalid" in login.error_message().lower()
Key reliability rule: Assert on stable UI state (header visible, error banner visible). Avoid assertions that rely on animations or transient content unless you explicitly wait for it.
Make Selectors Stable (The Most Overlooked Fix)
If you can change the application HTML, add attributes specifically for tests:
<input data-testid="email" /> <input data-testid="password" type="password" /> <button data-testid="login-submit">Sign in</button> <div data-testid="login-error">Invalid credentials</div>
This prevents tests from breaking when a designer changes class names or layout containers. Your UI can evolve while your tests remain stable.
Run the Tests
Run normally (headed browser):
pytest -q
Run headless:
HEADLESS=1 pytest -q
Point to a different environment (staging):
BASE_URL="https://staging.example.com" HEADLESS=1 pytest -q
Common Flakiness Traps (And Practical Fixes)
-
Trap: Using
time.sleep()
Fix: Replace withWebDriverWaitconditions like “clickable” and “visible.” -
Trap: Overusing implicit waits
Fix: Set implicit wait to0and standardize on explicit waits. Mixed waits can produce confusing timing behavior. -
Trap: Clicking elements covered by loaders/modals
Fix: Wait for overlays to disappear (e.g., wait for invisibility of a spinner) before clicking. -
Trap: Brittle selectors (CSS chains, positional selectors)
Fix: Preferdata-testid, IDs, or role-based selectors when available. -
Trap: Tests that depend on shared state
Fix: Create fresh test data per test or reset state via API/database fixtures. UI tests should be independent.
Where to Go Next
Once your smoke tests are stable, you can extend the same pattern to cover more flows without turning your suite into spaghetti:
- Add a
wait_invisible()helper for spinners and toast notifications. - Introduce “component objects” (e.g., a reusable
Tablehelper for sorting/searching). - Tag tests (smoke vs regression) and run smoke on every PR.
- Run against multiple browsers (Chrome + Firefox) only for critical paths.
If you keep your selectors stable, centralize waits, and hide UI mechanics behind Page Objects, Selenium becomes a practical tool for catching real user-impacting issues—without the constant flake-fighting.
Leave a Reply