Selenium with Python lets you drive a real browser from code — clicking, typing, navigating and asserting on what a user actually sees. You write the test in Python, Selenium speaks the W3C WebDriver protocol to Chrome, Firefox or Edge, and the browser does exactly what a person would. It is the standard way to write browser/UI automation and end-to-end (E2E) tests for web apps.
One honest clarification up front: despite the historical "unit testing with Selenium" phrasing, Selenium is not a unit-testing tool. A unit test exercises one function or class in memory, in milliseconds, with no browser. Selenium tests run the whole stack — server, JavaScript, DOM, network — through a real browser, so they are slower and broader. Keep the two layers separate:
- Unit tests (
pytest,unittest): fast, isolated logic checks. Most of your suite. - Integration tests: a few components together (e.g. a Django view plus its database).
- E2E / UI tests (Selenium, Playwright): a handful of high-value journeys — login, checkout, signup — exercised through the browser.
This is the classic test pyramid: many unit tests, fewer integration tests, a small set of slow-but-realistic E2E tests. Selenium lives at the top. This guide shows the modern Selenium 4.x way to write those E2E tests with Python 3.12+ and pytest, and where Playwright fits as an alternative. We run this exact stack on client projects at MicroPyramid, where we have shipped 50+ products over 12+ years and lean on test automation to release in days, not months.
What you will build
By the end you will have a small, reliable Selenium + pytest suite that:
- installs in two commands (no manual driver downloads — Selenium Manager handles that),
- uses explicit waits instead of
time.sleepso tests are not flaky, - locates elements with the W3C-compliant
find_element(By.…)API, - is organised with the Page Object Model so locators live in one place,
- runs headless in CI and captures a screenshot when a test fails.
Setup and installation
You need Python 3.12+ and a browser (Chrome, Edge or Firefox) installed on the machine. Install Selenium and pytest into a virtual environment:
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install selenium pytest
# optional, for nicer reporting and parallelism:
pip install pytest-html pytest-xdist
You do NOT need to download ChromeDriver
This is the single biggest change from older tutorials. Since Selenium 4.6, Selenium Manager is built in: the first time you launch a browser, Selenium detects your installed browser version, downloads the matching driver, caches it, and wires it up automatically. No webdriver-manager package, no chromedriver on your PATH, and no executable_path= argument (that argument was removed in Selenium 4.x and now raises an error).
Your first Selenium test
Here is a complete, modern test. It opens a page and asserts on its title. Note the imports and the driver construction — this is the Selenium 4 baseline:
# test_first.py
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
def test_homepage_title():
options = Options()
options.add_argument("--headless=new") # comment out to watch it run
# Service() with no path => Selenium Manager resolves the driver for you.
driver = webdriver.Chrome(service=Service(), options=options)
try:
driver.get("https://micropyramid.com/")
assert "MicroPyramid" in driver.title
finally:
driver.quit()
Run it:
pytest test_first.py -v
The simplest possible launch is even shorter — webdriver.Chrome() with no arguments also works, because Selenium Manager fills in the driver. Passing service=Service() explicitly is useful once you need to customise driver logging or pin a driver path, so it is the form used throughout this guide.
Locating elements: the By strategies
In Selenium 4 there is one finder method, find_element(by, value) (and find_elements(...) for lists). The old find_element_by_id, find_element_by_name, find_elements_by_partial_link_text and friends were removed — they no longer exist. Always import By:
from selenium.webdriver.common.by import By
# Single element (raises NoSuchElementException if missing)
driver.find_element(By.ID, "email")
driver.find_element(By.NAME, "password")
driver.find_element(By.CSS_SELECTOR, "button.login")
driver.find_element(By.XPATH, "//button[@type='submit']")
driver.find_element(By.LINK_TEXT, "Web Development")
driver.find_element(By.PARTIAL_LINK_TEXT, "Web Dev")
# Multiple elements (returns a list, empty if none)
links = driver.find_elements(By.CSS_SELECTOR, "a.nav-link")
Which locator should I use?
Prefer locators that are stable and readable. A rough order of preference:
By.ID— fastest and most stable when the element has a unique id.By.CSS_SELECTOR— the everyday workhorse; concise and powerful.By.XPATH— use when you must match on text content or traverse upward; more brittle.By.LINK_TEXT/By.PARTIAL_LINK_TEXT— handy for anchor links.
Tip: add stable hooks like data-testid="login-submit" to your markup and select with By.CSS_SELECTOR, "[data-testid='login-submit']". Test selectors that do not change when designers tweak classes are the cheapest way to keep a suite green.
Waits: the cure for flaky tests
Modern web apps render asynchronously, so an element may not exist the instant the page "loads". The number-one cause of flaky Selenium tests is acting before the page is ready. Never use time.sleep as your synchronisation strategy — it is either too short (flaky) or too long (slow). Use explicit waits.
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.common.by import By
wait = WebDriverWait(driver, timeout=10)
# Wait until the element is present in the DOM
email = wait.until(EC.presence_of_element_located((By.ID, "email")))
# Wait until it is actually clickable (visible + enabled)
button = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "button.login")))
button.click()
# Wait for text to appear / a condition to become true
wait.until(EC.text_to_be_present_in_element((By.ID, "flash"), "Signed in"))
Explicit vs implicit waits
| Aspect | Explicit wait (WebDriverWait) |
Implicit wait |
|---|---|---|
| Scope | Per call — wait for a specific condition | Global — applies to every find_element |
| Conditions | Rich (clickable, visible, text present, …) | Only "element present" |
| Control | Precise; recommended | Blunt; easy to misuse |
| Mixing | Use these | Avoid combining with explicit waits |
Recommendation: use explicit waits and set the implicit wait to 0. Mixing explicit and implicit waits can produce unpredictable, additive timeouts. Configure one strategy and stick to it.
Organise with the Page Object Model
As a suite grows, scattering CSS selectors across dozens of tests becomes unmaintainable — one redesign breaks everything. The Page Object Model (POM) puts the locators and actions for a page into a class. Tests then read like user intent, and when the UI changes you fix one file.
# 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:
URL = "https://example.com/login"
EMAIL = (By.ID, "email")
PASSWORD = (By.ID, "password")
SUBMIT = (By.CSS_SELECTOR, "button[type='submit']")
FLASH = (By.CSS_SELECTOR, "[data-testid='flash']")
def __init__(self, driver, timeout=10):
self.driver = driver
self.wait = WebDriverWait(driver, timeout)
def load(self):
self.driver.get(self.URL)
return self
def login(self, email, password):
self.wait.until(EC.visibility_of_element_located(self.EMAIL)).send_keys(email)
self.driver.find_element(*self.PASSWORD).send_keys(password)
self.driver.find_element(*self.SUBMIT).click()
return self
def flash_message(self):
return self.wait.until(EC.visibility_of_element_located(self.FLASH)).text
The test that uses it stays short and readable:
# test_login.py
from pages.login_page import LoginPage
def test_valid_login(driver):
page = LoginPage(driver).load()
page.login("user@example.com", "secret123")
assert "Welcome" in page.flash_message()
Running with pytest: a driver fixture
The cleanest way to manage browser setup and teardown is a pytest fixture in conftest.py. Defining the fixture once means every test receives a ready-to-use driver and the browser is always closed afterwards — even if the test fails.
# conftest.py
import pytest
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
def pytest_addoption(parser):
parser.addoption("--headed", action="store_true", help="run with a visible browser")
@pytest.fixture
def driver(request):
options = Options()
if not request.config.getoption("--headed"):
options.add_argument("--headless=new")
options.add_argument("--window-size=1920,1080")
options.add_argument("--no-sandbox") # needed in many CI containers
options.add_argument("--disable-dev-shm-usage")
drv = webdriver.Chrome(service=Service(), options=options)
drv.implicitly_wait(0) # we rely on explicit waits
yield drv
drv.quit()
Run headless (the default), or watch it locally with pytest --headed. To run the suite in parallel across CPU cores, add pytest -n auto (requires pytest-xdist).
Headless and CI
In CI you have no display, so run headless. The modern flag is --headless=new (the old --headless Chrome flag is legacy). The --no-sandbox and --disable-dev-shm-usage arguments above keep Chrome stable inside Docker and CI runners. Here is a minimal GitHub Actions job — note there is no driver-install step, because Selenium Manager fetches the driver at runtime:
# .github/workflows/e2e.yml
name: e2e
on: [push, pull_request]
jobs:
selenium:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install selenium pytest pytest-html
# Chrome is preinstalled on ubuntu-latest runners.
- run: pytest -v --html=report.html --self-contained-html
- uses: actions/upload-artifact@v4
if: always()
with:
name: e2e-report
path: |
report.html
screenshots/
Capture a screenshot when a test fails
A failed E2E test is far easier to debug with a picture of the broken page. Hook into pytest's report phase in conftest.py so any failing test that used the driver fixture saves a PNG automatically:
# conftest.py (add to the file above)
import os
import pytest
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == "call" and report.failed:
drv = item.funcargs.get("driver")
if drv is not None:
os.makedirs("screenshots", exist_ok=True)
path = os.path.join("screenshots", f"{item.name}.png")
drv.save_screenshot(path)
print(f"\nSaved screenshot: {path}")
Common pitfalls (and fixes)
- Flaky tests from
time.sleep. Replace everysleepwith an explicitWebDriverWaiton a real condition. This removes most flakiness. StaleElementReferenceException. The DOM re-rendered after you grabbed a reference. Re-find the element right before you use it (Page Objects make this natural — query inside the method, do not cacheWebElements as attributes).ElementClickInterceptedException. Something (a cookie banner, sticky header, modal) is covering the target. Dismiss the overlay first, or wait forelement_to_be_clickable.executable_path/find_element_by_*errors. You are following a Selenium 3 tutorial. UseService()andfind_element(By.…, …)as shown above.- Hard-coded test data and shared state. Each test should set up and tear down its own data so tests can run in any order and in parallel.
- Testing too much through the browser. If logic can be checked with a fast unit test, do that instead. Reserve Selenium for true user journeys.
Selenium vs Playwright vs Cypress
Selenium is the long-standing, W3C-standard, multi-language tool with the broadest browser and grid support. Playwright (which also has first-class Python bindings) is a strong modern alternative with built-in auto-waiting, network interception and faster setup; many teams starting fresh in 2026 pick it. Cypress is JavaScript/TypeScript only, so it is less relevant for Python teams. A quick comparison:
| Selenium | Playwright | Cypress | |
|---|---|---|---|
| Python support | Yes (first-class) | Yes (first-class) | No (JS/TS only) |
| Auto-waiting | Manual (explicit waits) | Built-in | Built-in |
| Browsers | Chrome, Firefox, Edge, Safari | Chromium, Firefox, WebKit | Chromium, Firefox, WebKit |
| Driver setup | Auto (Selenium Manager) | Bundled browsers | Bundled |
| Grid / scale | Selenium Grid (mature) | Built-in parallelism | Cypress Cloud |
| Best when | Cross-browser, existing suites, Safari, large grids | New projects wanting speed + auto-wait | JS/TS front-end teams |
When to choose Selenium: you need true cross-browser coverage (including Safari), you have an existing Selenium suite or grid, or your team standardises on the W3C WebDriver protocol. When to choose Playwright: a new Python project where built-in auto-waiting and faster execution outweigh Selenium's broader ecosystem. Both are excellent — the worst choice is no E2E coverage at all.
Where E2E tests fit in a real project
A healthy Python web app combines layers: thorough unit and view tests close to the code, plus a small Selenium or Playwright suite guarding the critical journeys. For Django specifically, pair these browser tests with Django unit test cases for forms and views so most of your coverage stays fast, and let Selenium guard only the end-to-end flows. If you are weighing how much testing a delivery actually needs, see why testing matters before delivering a project.
For teams that would rather not build and maintain this themselves, MicroPyramid's software testing services and Python development services cover test strategy, automation frameworks and CI integration — the same setup we use to ship and maintain client products faster.
Frequently Asked Questions
Is Selenium still relevant in 2026?
Yes. Selenium remains the W3C WebDriver standard and the most widely supported browser-automation tool across languages, browsers (including Safari) and large-scale grids. Selenium 4.x modernised the API and added Selenium Manager for zero-config drivers. Playwright is a strong newer alternative, but Selenium is very much alive and the right pick for cross-browser coverage and existing suites.
Is Selenium a unit-testing tool?
No — that is a common misnomer. Selenium drives a real browser, so it is for end-to-end and UI testing, not unit testing. Unit tests check a single function in memory in milliseconds with no browser. Use fast unit tests (pytest/unittest) for most coverage and reserve Selenium for a small set of high-value user journeys like login and checkout.
Do I still need to download ChromeDriver?
No. Since Selenium 4.6, the built-in Selenium Manager automatically detects your browser version, downloads the matching driver and caches it the first time you launch. You no longer need the webdriver-manager package, a driver on your PATH, or the removed executable_path= argument — just call webdriver.Chrome(service=Service()).
How do I avoid flaky Selenium tests?
Replace every time.sleep with explicit waits (WebDriverWait + expected_conditions) on real conditions like element_to_be_clickable. Re-find elements right before use to avoid StaleElementReferenceException, use stable data-testid locators, dismiss overlays before clicking, and give each test its own isolated data so order does not matter.
How do I run Selenium in CI and headless?
Launch Chrome with options.add_argument("--headless=new") plus --no-sandbox and --disable-dev-shm-usage for containers. In CI (for example GitHub Actions) you do not need a driver-install step — Selenium Manager fetches the driver at runtime. Run with pytest -v, capture screenshots on failure, and upload them as build artifacts for debugging.
Selenium or Playwright — which should I pick?
Choose Selenium for true cross-browser coverage (including Safari), an existing suite or grid, or a strict W3C WebDriver standard. Choose Playwright for a new Python project that benefits from built-in auto-waiting, network interception and faster execution. Both have first-class Python bindings; the important thing is to have E2E coverage of your critical flows at all.