Practical Selenium Automation with Python: Reliable UI Tests Using pytest + Page Objects
Selenium is still one of the most useful tools for end-to-end (E2E) testing when you need to verify a real browser journey: login, checkout, form validation, and “does this feature actually work in Chrome?”. The trap for many teams is writing flaky tests—tests that pass locally but fail in CI, or fail randomly due to timing.
This article shows a hands-on, junior/mid-friendly setup for pytest + Selenium with three reliability pillars:
- Page Objects (clean structure, reusable selectors)
- Explicit waits (stop guessing timing)
- Stable locators (use
data-testidwhen possible)
1) Install dependencies and project structure
We’ll use Python + pytest and Selenium 4. Selenium Manager (built into Selenium 4.6+) can automatically download the right driver for many environments, which reduces setup pain.
pip install -U selenium pytest
Suggested folder structure:
e2e/ pages/ base_page.py login_page.py dashboard_page.py tests/ test_login.py conftest.py pytest.ini
Create a pytest.ini to keep output clean and optionally mark E2E tests:
[pytest] addopts = -q markers = e2e: end-to-end browser tests
2) Use stable selectors: add data-testid attributes
Your tests will be more robust if your app includes test-friendly selectors. Ask your frontend to add attributes like:
<input data-testid="email" /> <input data-testid="password" type="password" /> <button data-testid="login-submit">Sign in</button> <div data-testid="toast">...</div>
Why? Classes and DOM structure change for styling; data-testid usually stays stable.
3) Create a WebDriver fixture (headless-ready)
Put this in conftest.py. It creates a browser per test and closes it automatically. It also supports running headless (useful for CI).
import os import pytest from selenium import webdriver from selenium.webdriver.chrome.options import Options @pytest.fixture def base_url(): # Change to your local dev URL or test env URL return os.getenv("BASE_URL", "http://localhost:3000") @pytest.fixture def driver(): options = Options() # Headless mode is great for CI; keep it optional for local debugging if os.getenv("HEADLESS", "1") == "1": options.add_argument("--headless=new") # Recommended stability flags for Linux/CI containers options.add_argument("--no-sandbox") options.add_argument("--disable-dev-shm-usage") # Slightly more consistent rendering size options.add_argument("--window-size=1280,900") d = webdriver.Chrome(options=options) d.implicitly_wait(0) # prefer explicit waits (we'll use WebDriverWait) yield d d.quit()
Tip: Notice implicitly_wait(0). Mixing implicit waits and explicit waits can create confusing delays. For reliable tests, go explicit.
4) Build a small “BasePage” with explicit waits
Create pages/base_page.py with helpers for finding elements safely and clicking/typing.
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC class BasePage: def __init__(self, driver, base_url): self.driver = driver self.base_url = base_url self.wait = WebDriverWait(driver, 10) def open(self, path="/"): self.driver.get(self.base_url + path) def el_visible(self, by_locator): return self.wait.until(EC.visibility_of_element_located(by_locator)) def el_clickable(self, by_locator): return self.wait.until(EC.element_to_be_clickable(by_locator)) def click(self, by_locator): self.el_clickable(by_locator).click() def type(self, by_locator, text, clear=True): el = self.el_visible(by_locator) if clear: el.clear() el.send_keys(text) def text_of(self, by_locator): return self.el_visible(by_locator).text
This avoids most flakiness: instead of “sleep 2 seconds and hope”, you wait until the element is truly ready.
5) Implement Page Objects: Login + Dashboard
Create pages/login_page.py:
from selenium.webdriver.common.by import By from .base_page import BasePage class LoginPage(BasePage): EMAIL = (By.CSS_SELECTOR, '[data-testid="email"]') PASSWORD = (By.CSS_SELECTOR, '[data-testid="password"]') SUBMIT = (By.CSS_SELECTOR, '[data-testid="login-submit"]') TOAST = (By.CSS_SELECTOR, '[data-testid="toast"]') def open_login(self): self.open("/login") def login_as(self, email, password): self.type(self.EMAIL, email) self.type(self.PASSWORD, password) self.click(self.SUBMIT) def toast_message(self): return self.text_of(self.TOAST)
Create pages/dashboard_page.py (a simple “post-login” assertion):
from selenium.webdriver.common.by import By from .base_page import BasePage class DashboardPage(BasePage): TITLE = (By.CSS_SELECTOR, '[data-testid="dashboard-title"]') USER_MENU = (By.CSS_SELECTOR, '[data-testid="user-menu"]') def title_text(self): return self.text_of(self.TITLE) def user_menu_visible(self): self.el_visible(self.USER_MENU) return True
Page Objects make your test files short and readable. When selectors change, you fix them once in the page class—not in 30 tests.
6) Write two tests: success and failure
Create tests/test_login.py:
import os import pytest from pages.login_page import LoginPage from pages.dashboard_page import DashboardPage @pytest.mark.e2e def test_login_success(driver, base_url): login = LoginPage(driver, base_url) dash = DashboardPage(driver, base_url) # Use env vars so credentials aren't hard-coded email = os.getenv("E2E_EMAIL", "[email protected]") password = os.getenv("E2E_PASSWORD", "correct-password") login.open_login() login.login_as(email, password) # Assert something stable on the dashboard assert dash.user_menu_visible() is True assert "Dashboard" in dash.title_text() @pytest.mark.e2e def test_login_invalid_password_shows_error(driver, base_url): login = LoginPage(driver, base_url) login.open_login() login.login_as("[email protected]", "wrong-password") msg = login.toast_message() assert "invalid" in msg.lower() or "incorrect" in msg.lower()
Run tests locally:
BASE_URL=http://localhost:3000 HEADLESS=0 pytest -m e2e
7) Common flakiness fixes (that actually work)
-
Replace
time.sleep()with explicit waits. If the UI is async (spinners, fetch requests), wait for a real condition: element visible, clickable, URL changed, text present, etc. -
Wait for navigation when clicking. Example: after submit, wait for a dashboard element to exist (as we did) instead of immediately asserting the URL.
-
Use
data-testidselectors. CSS class selectors like.btn-primaryare fragile. -
Keep tests independent. Avoid relying on data created by previous tests. If you need data, create it within the test (or reset state via API/DB fixtures).
-
Capture screenshots on failure. This turns “it failed in CI” into actionable debugging.
Here’s a simple screenshot-on-failure fixture you can add to conftest.py:
import pytest @pytest.hookimpl(hookwrapper=True, tryfirst=True) def pytest_runtest_makereport(item, call): outcome = yield rep = outcome.get_result() setattr(item, "rep_" + rep.when, rep) @pytest.fixture(autouse=True) def screenshot_on_failure(request, driver): yield if hasattr(request.node, "rep_call") and request.node.rep_call.failed: driver.save_screenshot("failure.png")
In a real project, generate unique filenames per test (e.g., include the test name and timestamp).
8) Next steps: scale this approach without pain
Once the basics are stable, you can evolve your suite safely:
-
Add a “components” layer for shared widgets (navbar, modal, date picker) so pages don’t duplicate logic.
-
Parallelize using
pytest-xdist(pip install pytest-xdist, thenpytest -n auto) once your tests are independent. -
Run against multiple browsers (Chrome + Firefox) for critical flows.
-
Use a test environment with consistent data and deterministic feature flags.
If you adopt Page Objects, explicit waits, and stable selectors from day one, Selenium becomes predictable and genuinely useful—rather than a flaky “CI lottery.”
Leave a Reply