PRACTICAL GUIDE / Selenium pytest parallel fixtures
Session-Safe Selenium Python Fixtures for Parallel pytest Runs
Design function-scoped Selenium Python fixtures for pytest-xdist with isolated drivers, collision-free artifacts, failure capture, and reliable cleanup.
In this guide11 sections
- Define Session Safety at the Test Boundary
- Record pytest Outcomes Without Owning the Driver
- Build Options Per Invocation
- Create One Driver and Guarantee Finalization
- Keep Artifact Names Safe Under Parametrization
- Separate Browser Isolation From Test Data Isolation
- Match xdist Concurrency to Browser Capacity
- Diagnose Failures by Lifecycle Phase
- Evaluate the Wider-Scope Tradeoff Honestly
- Run a Parallel Fixture Checklist
- End With One Owner Per Session
What you will learn
- Define Session Safety at the Test Boundary
- Record pytest Outcomes Without Owning the Driver
- Build Options Per Invocation
- Create One Driver and Guarantee Finalization
Parallel pytest does not make a Selenium fixture safe by itself. It only changes where tests run. A session-scoped driver can still leak cookies and windows between all tests assigned to one xdist worker, while a shared artifact filename can still be overwritten by several worker processes. Safety comes from matching each resource to the narrowest lifecycle that truly owns it.
For browser tests, make function scope the default: one test invocation creates one WebDriver, uses one isolated artifact directory, and quits in the generator fixture's finally block. Treat xdist worker identity as routing metadata, not as permission to reuse mutable browser state.
Define Session Safety at the Test Boundary
A WebDriver session contains more than a browser window. It owns cookies, local and session storage, window handles, alert state, downloads, timeouts, and server-side session identity. Deleting one cookie jar between tests cannot prove the rest is clean. The Selenium recommendation to avoid sharing state therefore aligns naturally with pytest's function-scoped fixtures.
pytest-xdist commonly executes tests in separate worker processes. That protects Python globals in one worker from direct memory sharing with another worker, but it does not isolate tests that run sequentially in the same process. A module- or session-scoped driver remains shared inside that worker. Scope, not process count, decides how many tests own a fixture instance.
Animated field map
Parallel pytest Browser Ownership
Each worker invokes a function-scoped fixture, owns one browser for one test, captures unique evidence, and finalizes the session locally.
01 / pytest worker
pytest Worker
A worker process receives a test but does not become the browser lifetime.
02 / fixture setup
Fixture Setup
Function scope builds options, paths, and one local or remote driver.
03 / isolated driver
Isolated WebDriver
Only the current test and its page objects receive this session.
04 / test artifacts
Test and Artifacts
The call report controls failure capture into a collision-free directory.
05 / fixture finalizer
Fixture Finalizer
Best-effort evidence runs before an unconditional guarded quit.
Record pytest Outcomes Without Owning the Driver
A fixture finalizer needs to know whether the test call failed, but it should not implement pytest's result model. Use pytest_runtest_makereport to attach each phase report to the test item. The hook stores metadata only; it never reaches into a driver or captures a screenshot.
# conftest.py
import pytest
@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
outcome = yield
report = outcome.get_result()
setattr(item, f"report_{report.when}", report)pytest distinguishes setup, call, and teardown outcomes. A failed assertion appears in report_call; fixture setup failures appear in report_setup. Capture policy should name which phases it handles. The browser fixture can reliably capture call failures because the driver has already been yielded and remains alive. If another fixture fails during setup after the browser exists, fixture finalizer ordering may determine what evidence is still available, so keep dependent fixtures explicit and small.
Build Options Per Invocation
Do not cache an options object globally. Browser option builders are mutable, and test-specific preferences can accumulate when the same object is reused. Construct options inside the fixture or return a new object from a pure factory for every request.
from selenium import webdriver
from selenium.webdriver.chrome.options import Options as ChromeOptions
from selenium.webdriver.firefox.options import Options as FirefoxOptions
def build_options(browser_name: str, headless: bool):
if browser_name == "chrome":
options = ChromeOptions()
if headless:
options.add_argument("--headless")
options.add_argument("--lang=en-US")
return options
if browser_name == "firefox":
options = FirefoxOptions()
if headless:
options.add_argument("-headless")
options.set_preference("intl.accept_languages", "en-US")
return options
raise pytest.UsageError(f"Unsupported browser: {browser_name}")The Selenium Python API demonstrates a generator fixture that yields a driver and quits afterward. The production additions are configuration validation, unique artifact ownership, guarded failure capture, and support for local or remote construction without changing the test-facing fixture.
Create One Driver and Guarantee Finalization
Keep driver initialized to None so a constructor failure does not produce a second cleanup exception. Put construction and yield inside one try/finally. Within the finalizer, make evidence capture and session release separate operations; a missing screenshot must never strand a remote slot.
import os
import re
from pathlib import Path
import pytest
from selenium import webdriver
def worker_id(config: pytest.Config) -> str:
worker_input = getattr(config, "workerinput", None)
return worker_input["workerid"] if worker_input else "main"
def artifact_directory(request: pytest.FixtureRequest) -> Path:
safe_node = re.sub(r"[^A-Za-z0-9_.-]+", "_", request.node.nodeid)
path = Path("artifacts") / worker_id(request.config) / safe_node
path.mkdir(parents=True, exist_ok=True)
return path
def capture_failure(driver, directory: Path) -> None:
try:
driver.save_screenshot(str(directory / "failure.png"))
(directory / "page-source.html").write_text(
driver.page_source,
encoding="utf-8",
)
except Exception as error:
(directory / "capture-error.txt").write_text(
f"{type(error).__name__}: {error}",
encoding="utf-8",
)
@pytest.fixture(scope="function")
def driver(request: pytest.FixtureRequest):
browser_name = request.config.getoption("--browser")
options = build_options(browser_name, headless=True)
grid_url = os.getenv("SELENIUM_GRID_URL")
owned_driver = None
try:
if grid_url:
owned_driver = webdriver.Remote(command_executor=grid_url, options=options)
elif browser_name == "chrome":
owned_driver = webdriver.Chrome(options=options)
else:
owned_driver = webdriver.Firefox(options=options)
yield owned_driver
finally:
if owned_driver is None:
return
report = getattr(request.node, "report_call", None)
if report is not None and report.failed:
capture_failure(owned_driver, artifact_directory(request))
try:
owned_driver.quit()
except Exception as error:
directory = artifact_directory(request)
(directory / "quit-error.txt").write_text(
f"{type(error).__name__}: {error}",
encoding="utf-8",
)Register --browser in pytest_addoption or replace it with typed project configuration. Avoid a fallback that silently turns an unknown browser into Chrome; configuration errors should fail before a costly suite begins.
Keep Artifact Names Safe Under Parametrization
request.node.name alone is not unique enough. Parametrized cases, repeated modules, retries, and separate workers can produce collisions. A sanitized full nodeid plus worker ID gives each invocation a deterministic directory. If a retry plugin creates multiple attempts for the same node ID, include its attempt identifier as another path component.
Do not use browser titles or URLs directly as filenames. They can contain separators, reserved characters, secrets, or unbounded text. Keep metadata inside a structured JSON document after redaction. Screenshots and page source can contain credentials and personal data, so apply the same retention and access policy as test logs.
Artifact writing also needs a failure policy. A full disk or permission error should be visible, but it should not replace the original assertion or prevent quit(). Report capture failure as secondary evidence and preserve the primary pytest outcome.
Separate Browser Isolation From Test Data Isolation
A fresh browser does not create a fresh account, order, mailbox, or database row. Parallel tests using the same user can invalidate one another's sessions even when their drivers are perfect. Allocate test data with unique IDs or worker-aware leases, and release those leases in their own fixtures.
Download directories require the same discipline. If local browsers write to disk, create a per-test directory and place it in that invocation's browser options before construction. For remote Grid sessions, remember that the browser filesystem is remote; a local path in the test worker is not automatically visible on the node.
Page objects should receive driver through constructors or fixture composition. A module-level current_driver turns an explicit dependency back into mutable global state. The fresh-browser guidance is valuable because it keeps failures local and makes tests easier to rearrange or run independently.
Match xdist Concurrency to Browser Capacity
-n auto chooses workers from the runner environment, not from available Grid stereotypes. If each test creates a remote session, worker count controls simultaneous admission pressure. Set it from the capacity of the requested browser pool and the application's tolerance for parallel load. A queue is useful for short bursts, but an ever-growing queue means the suite is overcommitted.
With a local browser per worker, account for memory, file descriptors, shared memory, and process cleanup. A test that leaves a driver alive can affect later tests even though function scope was declared; fixture scope is a contract only when the finalizer succeeds and failures are monitored.
Avoid sharing one driver through an inter-process manager or external singleton. WebDriver command ordering is session-specific, and concurrent callers can alter the same browsing context between locate, act, and assert steps. More workers should mean more owned sessions, not more clients racing over one session.
Diagnose Failures by Lifecycle Phase
A session-creation error before yield belongs to configuration, browser startup, driver discovery, or Grid admission. An assertion failure after yield belongs to test behavior and should trigger evidence capture. An error during quit() is cleanup evidence and may indicate a lost node or already-ended session. Preserve all three classifications rather than folding them into a generic fixture failure.
When tests pass alone but fail under xdist, look first for shared application data, artifact collisions, ports, downloads, environment mutation, and wider-scoped fixtures. If one worker accumulates dead sessions, inspect finalizer exceptions and process termination. If screenshots belong to the wrong test, verify node ID sanitization and retry naming before suspecting Selenium.
Evaluate the Wider-Scope Tradeoff Honestly
Module or session scope reduces browser startup cost, but it couples tests through every browser state surface and increases the blast radius of a crash. A failed navigation, modal dialog, or closed window can poison all later tests on that worker. Reset code then grows into an incomplete second browser lifecycle.
If a special suite must reuse a driver, mark it clearly, serialize the owning tests, validate state reset, and report it separately from isolated tests. Do not change the common driver fixture to session scope for speed and hope xdist makes it safe.
Run a Parallel Fixture Checklist
- The WebDriver fixture is function-scoped and yields exactly one owned session.
- Driver construction is inside a
try/finallywith aNoneguard. - Call-phase results are recorded by a hook that does not own browser resources.
- Failure evidence is attempted before quit and capture errors cannot skip cleanup.
- Artifact paths include full test identity, worker identity, and retry identity when used.
- Browser options and download directories are created per invocation.
- Page objects and helper fixtures receive the driver explicitly.
- Accounts, files, ports, and backend records are isolated independently.
- xdist worker count is bounded by matching browser and application capacity.
End With One Owner Per Session
The decisive rule is simple: one pytest invocation owns one browser session from construction through finalization. Worker processes decide placement; they do not redefine ownership. Once outcome metadata, artifacts, test data, and cleanup follow that same boundary, parallel execution becomes a capacity choice instead of a source of hidden state leaks.
// 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
What pytest fixture scope is safest for Selenium WebDriver?
Function scope is the safest default because every test receives a fresh driver and cleanup boundary. Wider scopes require deliberate state reset, serialization, and accepted failure coupling.
Does pytest-xdist make a session-scoped WebDriver safe?
No. xdist separates worker processes, but every test assigned to one worker would still share that worker's session-scoped driver and browser state. Process isolation is not test isolation.
How should a Selenium fixture capture screenshots on pytest failures?
Record the call-phase report in pytest_runtest_makereport, then capture from the fixture finalizer before quit. Artifact errors must be caught so cleanup still runs.
Why include the xdist worker ID in artifact paths?
Workers write concurrently. A worker ID plus a sanitized node ID prevents two parametrized or similarly named tests from overwriting the same screenshot or metadata file.
What happens if WebDriver construction fails before yield?
A try/finally around construction still executes, but there is no driver to quit. Keep the local initialized to None and make every cleanup operation conditional and independent.
RELATED GUIDES
Continue the learning route
GUIDE 01
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.
GUIDE 02
Selenium Grid Tutorial: Run Tests Across Browsers
Selenium Grid tutorial explaining architecture, setup, remote WebDriver, browser capabilities, parallel execution, Docker, CI, and debugging tips.
GUIDE 03
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 04
Designing a Cross-Browser Options Matrix in Selenium WebDriver 4
Design a Selenium browser options matrix that separates W3C capabilities, vendor settings, environments, and verified cross-browser expectations.