PRACTICAL GUIDE / selenium python tutorial
Selenium Python Tutorial: Build Your First Browser Test
Selenium Python tutorial for beginners covering setup, locators, waits, pytest fixtures, page objects, debugging, CI, and stable browser tests.
In this guide9 sections
- Set up an isolated test package
- Own the browser lifecycle with pytest
- Create a unique ticket fixture
- Write a page object around queue behavior
- Assert both visible and persisted state
- Synchronize with state transitions
- Handle authentication without coupling every test
- Save artifacts that identify the failure
- Run focused suites in CI
What you will learn
- Set up an isolated test package
- Own the browser lifecycle with pytest
- Create a unique ticket fixture
- Write a page object around queue behavior
A support-ticket test searches for "payment failed," clicks the first result, and passes because some ticket opened. In CI, shared data changes the ordering and the test validates the wrong record. The browser automation is technically working, but the test is not protecting a stable product contract.
This Selenium Python project uses pytest to create a unique ticket, find it through the agent console, change its priority, and verify the saved status. The example emphasizes readable Python, explicit synchronization, and fixtures with clear state ownership.
Set up an isolated test package
Create a virtual environment and install Selenium plus pytest through the repository's normal dependency workflow.
python -m venv .venv
source .venv/bin/activate
python -m pip install selenium pytest
pytest --collect-onlyLock dependencies with the tool the project already uses. Avoid a global Selenium installation because it makes developer and CI environments diverge. Selenium Manager can obtain a compatible driver in many normal setups, but it does not install the browser application. Offline CI runners and controlled networks may require a prebuilt browser image or an internal driver cache.
Use a simple structure before adding framework layers:
tests/
├── conftest.py
├── pages/ticket_queue.py
├── support/ticket_api.py
└── test_ticket_priority.pyMake configuration a small typed object or pytest fixtures rather than scattered os.getenv calls. Validate required API URLs and tokens during session setup. A typo should fail before a browser starts, not after the test has waited for an empty queue.
Use python -m pytest when interpreter selection is ambiguous. It guarantees pytest runs from the activated environment. Print Python, Selenium, browser, and driver information in a sanitized CI preamble so environment drift is visible in failure reports.
Own the browser lifecycle with pytest
A function-scoped fixture gives each test a clean WebDriver session. Browser choice and target URL come from pytest options so the same test can run locally or remotely.
# tests/conftest.py
import pytest
from selenium import webdriver
def pytest_addoption(parser):
parser.addoption("--app-url", action="store", required=True)
parser.addoption("--headless", action="store_true")
@pytest.fixture
def driver(request):
options = webdriver.ChromeOptions()
options.add_argument("--window-size=1440,900")
if request.config.getoption("--headless"):
options.add_argument("--headless=new")
browser = webdriver.Chrome(options=options)
browser.implicitly_wait(0)
yield browser
browser.quit()
@pytest.fixture
def app_url(request):
return request.config.getoption("--app-url").rstrip("/")The yield fixture guarantees teardown after the test body, but a browser startup failure occurs before the yield and needs no quit. If teardown itself fails, preserve the original test exception in reporting.
For Grid, replace webdriver.Chrome with webdriver.Remote(command_executor=..., options=options). Do not share one remote driver across pytest workers.
Create a unique ticket fixture
The test should find its own record, not depend on a long-lived fixture named "payment failed." Wrap the service API in a small client and yield a typed value.
# tests/conftest.py
from dataclasses import dataclass
from uuid import uuid4
from tests.support.ticket_api import TicketApi
@dataclass(frozen=True)
class Ticket:
ticket_id: str
reference: str
@pytest.fixture
def ticket():
api = TicketApi.from_environment()
reference = f"SEL-{uuid4().hex[:10]}"
created = api.create(
reference=reference,
subject="Card payment failed",
priority="normal",
)
yield Ticket(ticket_id=created["id"], reference=reference)
api.delete(created["id"])The API client should use a bounded timeout, validate response status, and avoid printing credentials. If deletion can fail because the UI archives tickets, make cleanup idempotent according to an explicit backend contract. Do not suppress every cleanup error.
Unique data supports parallelism and makes screenshots self-identifying. It also prevents the test from asserting on a record edited by a manual tester.
Use factories for meaningful data variations, not a single fixture with dozens of optional arguments. A ticket_factory can create normal, urgent, or assigned tickets and register every created ID for teardown. The test should request the smallest state that expresses its precondition.
If the environment prohibits deletion for audit reasons, mark test records with a dedicated tenant and expiry timestamp. Cleanup can then archive them through an approved process. Never let a nightly job delete by subject text because a manual ticket can share the same words.
Write a page object around queue behavior
Use Selenium's By locators and WebDriverWait. Store locators, not previously found elements, so rerendering can replace the DOM safely.
# tests/pages/ticket_queue.py
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import Select, WebDriverWait
class TicketQueue:
SEARCH = (By.CSS_SELECTOR, "[data-testid='ticket-search']")
RESULTS = (By.CSS_SELECTOR, "[data-testid='ticket-results']")
PRIORITY = (By.ID, "ticket-priority")
SAVE = (By.CSS_SELECTOR, "[data-testid='save-ticket']")
STATUS = (By.CSS_SELECTOR, "[role='status']")
def __init__(self, driver, base_url: str, timeout: float = 10):
self.driver = driver
self.base_url = base_url
self.wait = WebDriverWait(driver, timeout)
def open(self):
self.driver.get(f"{self.base_url}/agent/tickets")
self.wait.until(EC.visibility_of_element_located(self.SEARCH))
def open_ticket(self, reference: str):
search = self.driver.find_element(*self.SEARCH)
search.clear()
search.send_keys(reference)
row = (By.CSS_SELECTOR, f"[data-ticket-reference='{reference}']")
self.wait.until(EC.element_to_be_clickable(row)).click()
self.wait.until(EC.url_contains("/agent/tickets/"))
def set_priority(self, priority: str):
control = self.wait.until(
EC.visibility_of_element_located(self.PRIORITY)
)
Select(control).select_by_value(priority)
self.wait.until(EC.element_to_be_clickable(self.SAVE)).click()
self.wait.until(
EC.text_to_be_present_in_element(self.STATUS, "Ticket saved")
)The code assumes priority is a native select. A custom combobox needs its actual accessible interaction, not Selenium's Select helper. The reference format is restricted to letters, digits, and hyphen, so interpolation into the attribute selector is controlled.
Assert both visible and persisted state
The test stays short because setup and page mechanics have clear owners.
# tests/test_ticket_priority.py
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
from tests.pages.ticket_queue import TicketQueue
from tests.support.ticket_api import TicketApi
def test_agent_marks_payment_ticket_urgent(driver, app_url, ticket):
queue = TicketQueue(driver, app_url)
queue.open()
queue.open_ticket(ticket.reference)
queue.set_priority("urgent")
selected = driver.find_element(By.ID, "ticket-priority")
assert selected.get_attribute("value") == "urgent"
api = TicketApi.from_environment()
WebDriverWait(driver, 10).until(
lambda _: api.get(ticket.ticket_id)["priority"] == "urgent"
)The visible value proves the control updated. The API poll proves persistence and handles a short asynchronous save. Because the lambda performs network calls, the API client needs a request timeout shorter than the WebDriver wait. For slower jobs, write a dedicated polling helper with an interval, deadline, and useful final error rather than overloading a browser wait.
If the UI sends an optimistic success before the backend rejects the change, this test catches the mismatch. If such backend verification is outside the UI suite's intended boundary, reload the page and assert the selected value instead.
Keep assertion messages tied to the ticket reference. Pytest shows expression values well, but a list or table mismatch can still be hard to map to setup. A short message such as f"priority for {ticket.reference}" gives the failure a business key without exposing customer content.
Avoid asserting every field returned by the API. The UI journey owns priority change, so status, timestamps, assignee formatting, and unrelated metadata belong in their own contract tests. Focused checks survive additive response changes and name the risk clearly.
Synchronize with state transitions
Explicit waits should describe why the next operation is safe. Common conditions include:
visibility_of_element_locatedfor content a user must read.element_to_be_clickablefor a control that must accept input.invisibility_of_element_locatedfor a blocking overlay.url_containsafter route navigation.frame_to_be_available_and_switch_to_itfor iframe content.- A custom callable for a product-specific state such as a row count.
Do not call time.sleep() after every action. Fixed delays slow successful runs and cannot adapt to a slower environment. Also avoid mixing a large implicit wait with explicit waits, because element lookup inside a condition can extend the apparent timeout.
When a stale element appears after table refresh, locate it again through the stored tuple. Do not cache the row in the page object's constructor. Retrying a destructive click after a stale exception may submit twice, so wait for a stable precondition before the first click.
Custom wait callables should catch only exceptions expected during the transition. Ignoring every WebDriverException can turn a crashed browser into a ten-second timeout. If a status can become failed, raise immediately with that state rather than polling until the outer deadline.
For tables that update through background requests, wait for a row keyed by ticket ID and then assert its cells. A spinner disappearing is not enough because it may be absent when the request never started. Pair a negative readiness signal with a positive result.
Handle authentication without coupling every test
Keep one browser-level login test. For other agent-console tests, establish the supported session through an API and add the returned cookie before visiting the protected route.
driver.get(app_url)
driver.add_cookie(session["cookie"])
driver.get(f"{app_url}/agent/tickets")WebDriver requires a page on the cookie's domain before add_cookie. The returned cookie mapping must contain the required name and value plus domain or path attributes that match the test environment. Do not reuse a saved cookie beyond its intended lifetime or commit it as fixture data.
Authenticate as the role under test. An administrator session can make permission-sensitive tests pass while actual agents are blocked.
Save artifacts that identify the failure
Use a pytest hook to capture a screenshot after a failed call phase. Include the test node ID and a sanitized unique suffix in the filename. Save the current URL, browser capabilities, and console entries when available.
from pathlib import Path
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
if report.when == "call" and report.failed:
browser = item.funcargs.get("driver")
if browser:
Path("artifacts").mkdir(exist_ok=True)
safe_name = item.nodeid.replace("/", "_").replace("::", "__")
browser.save_screenshot(f"artifacts/{safe_name}.png")Sanitize additional characters for the target filesystem when needed. Screenshot code must never replace the original exception. Avoid saving full page source on pages that expose customer messages or authentication tokens.
For a failure that occurs only in CI, compare the ticket reference with API logs, check browser console output, and verify the application health response. Increasing the wait is justified only when evidence shows a legitimate transition exceeded the current bound.
Create a compact text attachment with current URL, window handles, frame count, browser capability names, and ticket reference. This is often more diagnostic than a screenshot of a blank page. Keep artifact filenames unique across pytest workers by including the worker ID or a random suffix.
Capture artifacts during the call failure before driver.quit. If fixture teardown deletes the ticket first, the evidence may no longer match the state that failed. Report cleanup separately and make it possible for an authorized engineer to retain a failed record temporarily when deeper investigation is required.
Run focused suites in CI
Mark release-critical checks and run them against a controlled test environment. Publish screenshots and pytest reports even on failure.
# pytest.ini
[pytest]
markers =
smoke: critical agent workflows
regression: broader browser coverage- name: Run Selenium smoke suite
run: pytest -m smoke --app-url "${{ vars.TEST_APP_URL }}" --headless
env:
TICKET_API_URL: ${{ vars.TICKET_API_URL }}
TICKET_API_TOKEN: ${{ secrets.TICKET_API_TOKEN }}
- name: Upload Selenium artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: selenium-python-artifacts
path: artifactsInstall the browser explicitly on the runner or use a pinned image. If pytest workers are added, confirm each test owns its records, downloads, and WebDriver. A sound Selenium Python suite uses Python's readability to expose testing intent: a known starting record, a locator tied to the product, a wait tied to readiness, and an assertion that proves the change survived beyond the click.
Add cross-browser jobs according to supported customer environments, not as a default Cartesian product. A small smoke set can run on each required browser while deeper ticket scenarios run on the primary browser. Keep marker selection in reviewed CI configuration.
When runtime grows, measure browser startup, setup API time, page interaction, and teardown separately. Session reuse may look attractive, but it trades startup cost for state leakage. Prefer parallel independent sessions until measurements prove browser startup is the dominant constraint and the team can maintain a safe reset contract.
// FIELD DISPATCH
Get the QA Field Notes
Weekly QA battles, AI testing guides, and interview drills. Free on Substack.
// LIVE COURSE / THE TESTING ACADEMY
Playwright Automation Mastery
Go beyond Selenium. Master Playwright with JS/TS in 90 days.
From the instructor behind this guide.
Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.
PRIMARY REFERENCES
Verify the details at the source
QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.
- 01Selenium documentation
Selenium Project
Canonical WebDriver, Grid, waits, element, and browser automation guidance.
- 02WebDriver standard
W3C
The browser automation protocol specification behind WebDriver implementations.
FAQ / QUICK ANSWERS
Questions testers ask
Is Python good for Selenium automation?
Python is a strong choice for Selenium because the syntax is readable, the ecosystem is broad, and pytest gives excellent test organization. It is especially useful for QA engineers who want automation without the ceremony of Java, though large enterprise teams may still choose Java for existing infrastructure.
Should I use unittest or pytest with Selenium Python?
Use pytest unless your organization has a specific unittest standard. Pytest has simple test discovery, fixtures, parametrization, rich plugins, and cleaner setup for browser sessions. It also makes it easier to build readable automation without creating heavy class hierarchies too early.
Do I need to install browser drivers manually?
Modern Selenium can manage drivers through Selenium Manager in many setups, which reduces manual driver downloads. Still, CI environments may require explicit browser installation, matching driver versions, or container images. Always verify the exact behavior on the machine that runs the suite, not only your laptop.
What is the biggest Selenium Python beginner mistake?
The biggest mistake is using time.sleep as the main synchronization strategy. Browser automation fails when the script assumes timing instead of waiting for a real condition. Learn explicit waits early, because they make tests faster, more stable, and easier to debug.
Can Selenium Python be used for scraping?
It can automate browser actions, but testing and scraping have different goals. For QA, focus on repeatable assertions and product risk. If you scrape, respect site terms and rate limits. Do not let scraping patterns, such as brittle DOM traversal, leak into your test automation design.
RELATED GUIDES
Continue the learning route
GUIDE 01
Selenium Wait Commands: Implicit, Explicit, and Fluent Waits
Selenium wait commands explained with implicit, explicit, and fluent waits, practical examples, timing mistakes, flake fixes, and stable patterns.
GUIDE 02
Handle Iframes in Selenium: Switch Frames Without Flaky Tests
Handle iframes in Selenium with practical examples for switching frames, locating nested content, waits, errors, third-party widgets, and stable tests.
GUIDE 03
Page Object Model: A Pattern for Maintainable Tests
Learn the page object model for maintainable UI automation tests, with Playwright examples, best practices, and common POM mistakes to avoid.
GUIDE 04
Selenium vs Playwright vs Cypress in 2026
Compare Selenium vs Playwright vs Cypress in 2026 for speed, browsers, CI, flakiness, ecosystem, and which web automation framework beginners should choose.