PRACTICAL GUIDE / Selenium pytest xdist WebDriver isolation
Stop parallel Selenium tests from stealing each other's state
Give every parallel Selenium test its own browser, data identity, teardown, and artifact path, then diagnose collisions without hiding the first failure.
In this guide6 sections
- Why xdist reveals ownership bugs
- Build one browser and one evidence trail per test
- Keep backend data and artifact files out of the collision zone
- Diagnose the collision before changing fixture scope
- Roll out the isolation contract without flooding CI
- Accept the cost, and know when a different boundary is honest
What you will learn
- Why xdist reveals ownership bugs
- Build one browser and one evidence trail per test
- Keep backend data and artifact files out of the collision zone
- Diagnose the collision before changing fixture scope
Two checkout tests pass alone, then one starts from an authenticated page when CI adds -n 4. Both screenshots are named failure.png, so the second failure overwrites the first and makes the browser leak look random. The suite is not short of retries; it has no reliable owner for browser sessions, test records, or evidence.
Why xdist reveals ownership bugs
Pytest-xdist is a plugin that adds distributed execution to pytest. It is not part of pytest core. When you run pytest with several workers, xdist starts separate worker processes under a controller. Each worker performs collection and runs the tests assigned to it. That process boundary matters because it changes what can actually be shared.
A Python module global is not one object shared by all local xdist workers. Each process imports the module and gets its own memory. If a framework keeps a driver in a module global, four workers can create four unrelated globals. Calling that a cross-worker shared driver hides the real defect. The problem is that every test handled by one worker may reuse that worker's driver, while resources outside process memory can still collide across workers.
Fixture scope follows the same process boundary. A session-scoped fixture is cached for a pytest session, and each xdist worker runs its own session. The pytest-xdist documentation explicitly warns that high-scope fixtures execute more than once across workers. A session-scoped WebDriver fixture therefore tends to create one browser session per worker, not one browser for the whole distributed run. If worker gw2 receives six tests, those six tests can inherit the same cookies, local storage, open windows, downloads, and current URL unless the suite performs a complete reset between them.
That distinction explains a common login failure. One test authenticates an administrator and finishes without signing out. A later test expects the public login page. When both land on the same worker, the later test sees the administrator dashboard. When they land on different workers, it passes. Increasing the retry count changes the scheduling opportunity, so the retry can pass without correcting the leak.
The default load scheduler sends pending work to available workers without guaranteeing the order a particular worker will receive tests. A stable serial order can therefore disappear as soon as -n is enabled. The scheduler did not create the dependency. It exposed a test that depended on browser or application state left by another test.
Selenium's own test-practice guidance recommends a new WebDriver instance per test and specifically describes a yielding pytest fixture that quits the driver after the test. A new driver normally gives the test a new browser profile and a clean session boundary. It does not reset the application's database, a shared mailbox, a payment sandbox, or a fixed user account. Browser isolation is one layer, not a universal clean-room guarantee.
Use four identities when investigating a parallel failure. The pytest node ID identifies the test case. The xdist worker ID identifies the process that ran it. The Selenium session ID identifies the browser session controlled by that driver. The xdist test-run UID identifies one distributed invocation and has the same value across its workers. None of those values replaces the others.
The worker_id fixture returns names such as gw0 and gw1 when distribution is active, and master when xdist is installed but distribution is disabled with -n0. The testrun_uid fixture supplies a value shared by the workers in one run. A worker ID alone is not unique across CI jobs because the next job will also have a gw0. A test-run UID alone is not unique per test because every worker in that invocation sees the same value. A node ID alone is reused by retries and by future runs. Good ownership combines the identities at the scope where the resource is created.
Start with an owner statement for each mutable resource: this test creates it, this attempt writes it, and this fixture removes it. Apply that statement to the driver, any application records, downloaded files, screenshots, logs, and temporary directories. A resource whose owner is described as "the suite" deserves scrutiny. Some suite-level resources are valid, but they need an explicit concurrency contract rather than an accidental global name.
Build one browser and one evidence trail per test
A useful driver fixture has three jobs. It creates exactly one WebDriver for the requesting test, records enough identity to diagnose the session, and ends the session even when the assertion fails. Keeping those jobs together makes ownership visible in code review.
The following conftest.py example is runnable with pytest, pytest-xdist, Selenium, and Chrome available. It uses documented pytest report hooks to learn whether the call phase failed. It writes a manifest before browser startup, updates it after a session exists, captures a screenshot for a failed call when the browser still responds, and calls quit during fixture teardown. Every attempt gets a separate directory, including a retry of the same node ID.
# conftest.py
import hashlib
import json
import os
from pathlib import Path
from uuid import uuid4
import pytest
from selenium import webdriver
from selenium.common.exceptions import WebDriverException
def _node_key(nodeid: str) -> str:
return hashlib.sha256(nodeid.encode("utf-8")).hexdigest()[:16]
def _write_manifest(path: Path, values: dict[str, object]) -> None:
path.write_text(
json.dumps(values, indent=2, sort_keys=True),
encoding="utf-8",
)
@pytest.hookimpl(wrapper=True, tryfirst=True)
def pytest_runtest_makereport(item, call):
report = yield
setattr(item, f"rep_{report.when}", report)
return report
@pytest.fixture
def attempt_id() -> str:
return uuid4().hex[:12]
@pytest.fixture
def artifact_dir(request, worker_id, testrun_uid, attempt_id) -> Path:
root = Path(os.environ.get("TEST_ARTIFACT_ROOT", "artifacts"))
path = (
root
/ testrun_uid
/ worker_id
/ _node_key(request.node.nodeid)
/ attempt_id
)
path.mkdir(parents=True, exist_ok=False)
return path
@pytest.fixture
def driver(request, worker_id, testrun_uid, attempt_id, artifact_dir):
manifest_path = artifact_dir / "manifest.json"
manifest: dict[str, object] = {
"artifact_dir": str(artifact_dir),
"attempt_id": attempt_id,
"nodeid": request.node.nodeid,
"session_id": None,
"state": "starting",
"testrun_uid": testrun_uid,
"worker_id": worker_id,
}
_write_manifest(manifest_path, manifest)
options = webdriver.ChromeOptions()
if os.environ.get("HEADLESS", "1") == "1":
options.add_argument("--headless")
try:
owned_driver = webdriver.Chrome(options=options)
except WebDriverException as exc:
manifest["state"] = "start_failed"
manifest["startup_error_type"] = type(exc).__name__
_write_manifest(manifest_path, manifest)
raise
quit_error: WebDriverException | None = None
try:
manifest["browser_name"] = owned_driver.capabilities.get("browserName")
manifest["browser_version"] = owned_driver.capabilities.get("browserVersion")
manifest["session_id"] = owned_driver.session_id
manifest["state"] = "running"
_write_manifest(manifest_path, manifest)
yield owned_driver
finally:
call_report = getattr(request.node, "rep_call", None)
manifest["call_outcome"] = (
call_report.outcome if call_report is not None else "not_run"
)
if call_report is not None and call_report.failed:
screenshot_path = artifact_dir / "failure.png"
try:
saved = owned_driver.save_screenshot(str(screenshot_path))
manifest["screenshot"] = (
str(screenshot_path)
if saved
else "save_screenshot returned False"
)
except WebDriverException as exc:
manifest["screenshot_error_type"] = type(exc).__name__
try:
owned_driver.quit()
except WebDriverException as exc:
quit_error = exc
manifest["quit_error_type"] = type(exc).__name__
manifest["state"] = "quit_failed"
else:
manifest["state"] = "quit"
_write_manifest(manifest_path, manifest)
if quit_error is not None:
raise RuntimeError(
f"WebDriver quit failed for session {owned_driver.session_id}"
) from quit_errorFunction scope is the default because no scope argument appears on the fixtures. Pytest creates artifact_dir and driver for each test invocation that requests driver. The fixture variable is local to that invocation. There is no registry that another test can use to retrieve the object.
The startup record is deliberate. If Chrome or a remote Grid cannot create a session, the fixture never reaches yield. Pytest will not execute code after yield for a fixture that failed before yielding, so relying only on teardown to write diagnostics loses the startup failure. Writing state starting first and state start_failed in the exception path leaves evidence without pretending a session ID existed.
The fixture records the exception class for startup, screenshot, and teardown failures, but it does not copy an entire exception message into the manifest. Remote error text can contain environment details or URLs. Keep the full traceback in the pytest report, where normal access controls and redaction apply, and use the manifest as a correlation index.
A failed test call and a failed fixture teardown are different events. The hook stores the report for each phase on the test item. The fixture checks rep_call because a screenshot of an assertion failure is usually useful. If setup fails, there may be no call report. If quit fails after a passing assertion, raising a teardown error stops CI from presenting an unclosed session as a clean pass. Teams sometimes choose to downgrade teardown errors, but that decision should be explicit because leaked remote sessions consume capacity for later tests.
Use quit rather than close for ownership cleanup. Selenium documents quit as the operation that ends the session, while close deals with a window. Closing the current window is not a dependable substitute for deleting the WebDriver session. A test should also avoid calling driver.quit directly when the fixture owns teardown. Otherwise a test can end the session early and make the fixture's later diagnostics operate on a session that is already gone.
The manifest does not prove the product works. It proves which automation resources were present when the product assertion ran. A checkout test must still assert the order confirmation, total, or persisted state that represents the requirement. An assertion that merely checks a manifest contains its hard-coded worker ID can pass while checkout is broken.
Consider the administrator leak again. Run the two tests with the old session-scoped fixture and record one manifest per test before changing the scope. If both node IDs on gw2 report the same nonempty Selenium session ID, there is direct evidence of browser reuse. If the second test's screenshot shows an authenticated page, the browser state and symptom agree. Changing to the function-scoped fixture should produce distinct session IDs for those node IDs. The product assertion is still responsible for proving the login page is visible to an unauthenticated user.
The cost is browser startup. Four workers running function-scoped drivers can request four sessions at once, and a long suite will create many sessions over time. Local CPU, memory, and Grid slot limits become visible. Do not hide that cost by quietly returning to a session-scoped driver. Set worker count to a capacity the environment can support, and measure the suite in your own CI before deciding whether the extra isolation is worth a broader optimization.
Keep backend data and artifact files out of the collision zone
Fresh browsers can still race over one customer, one order, or one file. This is the failure mode that produces the most wasted framework work: the team replaces the driver fixture, sees new session IDs everywhere, and the tests continue to fail because the shared object lives in the application.
Suppose two checkout cases log in as qa-buyer@example.test. One empties the cart during setup while the other adds a product. The browser sessions are independent, but the server sees both requests against the same account. A screenshot from one test can show an empty cart even though its own browser added an item correctly. No WebDriver change can isolate that account.
Give mutable data a test-level identity. The next fixture produces a bounded, URL-safe token from the run, worker, node, and attempt. It uses a random suffix because the same node may be retried during one run. The token is suitable for fields with a known maximum length, and the email fixture uses the reserved example.test domain so the example cannot target a real mailbox.
# conftest.py
import hashlib
import re
from dataclasses import dataclass
import pytest
@dataclass(frozen=True)
class TestIdentity:
testrun_uid: str
worker_id: str
nodeid: str
attempt_id: str
def token(self, prefix: str, max_length: int = 48) -> str:
raw = f"{prefix}-{self.testrun_uid}-{self.worker_id}"
safe = re.sub(r"[^a-z0-9]+", "-", raw.lower()).strip("-")
node_hash = hashlib.sha256(
self.nodeid.encode("utf-8")
).hexdigest()[:8]
suffix = f"{node_hash}-{self.attempt_id}"
if max_length <= len(suffix) + 1:
raise ValueError("max_length must leave room for a stem and suffix")
stem_length = max_length - len(suffix) - 1
return f"{safe[:stem_length].rstrip('-')}-{suffix}"
@pytest.fixture
def test_identity(request, worker_id, testrun_uid, attempt_id) -> TestIdentity:
return TestIdentity(
testrun_uid=testrun_uid,
worker_id=worker_id,
nodeid=request.node.nodeid,
attempt_id=attempt_id,
)
@pytest.fixture
def customer_email(test_identity) -> str:
local_part = test_identity.token("checkout")
return f"{local_part}@example.test"A unique string is only the naming half of data isolation. The fixture that creates the real customer or order should retain the returned application ID and delete that exact record in teardown. Use the application's real administrative client and documented deletion behavior. Do not write a cleanup query that deletes every row whose name starts with checkout, because another test or a concurrent CI run can legitimately own a matching row.
Pytest's safe-teardown guidance favors fixtures that perform one state-changing action and pair it with its cleanup. That shape matters when setup is only partly successful. If a large fixture creates a customer, creates an order, configures a discount, and then fails before yield, code placed after yield will not run for any of those changes. Smaller fixtures let already-completed resources register their own teardown path.
Deletion can fail, and a unique name does not make stale data harmless forever. Record the application ID, the identity token, and the cleanup result alongside the browser manifest. When cleanup fails, report it as teardown evidence and keep the original test result. A nightly janitor can remove expired test records, but it should use ownership metadata and an age rule defined by the application team. It should not guess from a loose prefix.
The run UID and worker ID serve different purposes in data names. The run UID separates two CI jobs that both contain gw0. The worker ID makes it easy to locate the responsible process. The node hash ties the record to a test without leaking a long parameter value into systems with short name limits. The random attempt ID protects a retry from inheriting the first attempt's record. If the application enforces a shorter key, reduce the readable stem, not the unique suffix.
Artifacts need the same treatment. Writing every failed screenshot to artifacts/failure.png creates a last-writer-wins race. Even if operating-system writes are atomic, the filename still has several owners. A passing retry that writes to the same directory can also replace the only evidence from the failed attempt.
The artifact hierarchy in the driver fixture separates run, worker, node hash, and attempt. It keeps failure.png readable because the directory already carries the identity. Logs, page source, downloads, and browser console exports can live beside it. Do not place credentials, raw authentication headers, or unrestricted application data there merely because the path is unique.
For throwaway files used only during one test, pytest's tmp_path fixture is simpler. Pytest documents tmp_path as unique to each test invocation, and xdist arranges worker temporary data under a per-run base directory on local distributed runs. A test can point its browser download directory at a child of tmp_path without designing its own name. The trade-off is retention: CI artifact upload tools do not automatically discover pytest's temporary root, and pytest's retention policy is not the same as your incident-retention policy. Use tmp_path for disposable work and a deliberate artifact root for evidence that CI must publish.
A third collision appears when a suite gives every Chrome instance the same explicit profile directory. That directory is external process state, so separate Python workers do not make it private. Avoid supplying a shared profile to parallel sessions. If a test genuinely requires a prepared profile, copy the prepared, immutable seed into a per-test temporary directory and let that test own the copy. The copy adds disk and startup cost, which should be visible in the suite design.
The useful question is not whether a value includes gw0. Ask whether two concurrently active attempts can resolve to the same mutable resource. A file keyed only by worker is safe from other workers but can be overwritten by consecutive tests on that worker. An account keyed only by test node can collide with a retry or another CI run. An order keyed by a random UUID is unlikely to collide, but it still needs traceability and cleanup. Ownership requires uniqueness, correlation, and lifecycle together.
Diagnose the collision before changing fixture scope
Parallel-only is a symptom category, not a root cause. Browser reuse, backend data races, artifact overwrites, Grid capacity, test-order dependencies, and ordinary timing defects can all first appear when -n increases. Treat each as a hypothesis with evidence that could reject it.
A small audit program can turn the browser manifests into a useful gate. This script fails if no manifests exist, if required fields are missing, if a successful browser start has no session ID, if a manifest never reaches a terminal lifecycle state, if one session ID is attributed to more than one test node, if a manifest sits in a directory that does not spell out its own recorded identity, or if two manifests carry the same identity.
That directory check needs care, because the obvious version of it cannot fail. Grouping manifests by the artifact_dir field they contain sounds like a duplicate detector, but the fixture writes each manifest into that same directory, so the key is always the file's own parent and two distinct files can never share it. Worse, the regression this section is about destroys the evidence before the gate runs. Key artifact_dir on the worker alone and two tests on gw0 resolve to one directory; the second test overwrites the first test's manifest and its failure screenshot, one file survives, and a duplicate-key gate reports OK checked 1 manifests and 1 browser sessions and exits 0. Ask what would have to change for a check to fail. If the answer is nothing, it is decoration.
The check that does fail rebuilds the path each attempt is entitled to from the fields inside its own manifest, then compares that to where the file actually turned up. A directory keyed by fewer fields than the identity it holds is reachable by more than one attempt by construction, and the mismatch says so.
# tools/audit_browser_owners.py
from __future__ import annotations
import hashlib
import json
import sys
from collections import defaultdict
from pathlib import Path
REQUIRED = {
"artifact_dir",
"attempt_id",
"nodeid",
"session_id",
"state",
"testrun_uid",
"worker_id",
}
TERMINAL_STATES = {"start_failed", "quit", "quit_failed"}
def _node_key(nodeid: str) -> str:
return hashlib.sha256(nodeid.encode("utf-8")).hexdigest()[:16]
def owning_path(record: dict[str, object]) -> tuple[str, ...]:
"""The directory this attempt is entitled to, derived from its own fields."""
return (
str(record["testrun_uid"]),
str(record["worker_id"]),
_node_key(str(record["nodeid"])),
str(record["attempt_id"]),
)
def main(root: Path) -> int:
manifest_paths = sorted(root.rglob("manifest.json"))
if not manifest_paths:
print(f"ERROR no browser manifests found below {root}")
return 1
problems: list[str] = []
sessions: dict[str, set[tuple[str, str, str]]] = defaultdict(set)
identities: dict[tuple[str, str, str, str], list[str]] = defaultdict(list)
for path in manifest_paths:
try:
record = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
problems.append(f"{path}: unreadable manifest ({type(exc).__name__})")
continue
missing = REQUIRED.difference(record)
if missing:
problems.append(f"{path}: missing fields {sorted(missing)}")
continue
owner = (
str(record["worker_id"]),
str(record["nodeid"]),
str(record["attempt_id"]),
)
identities[(str(record["testrun_uid"]), *owner)].append(str(path))
# The directory has to spell out the identity written inside the file.
# A directory keyed by fewer fields is reachable by more than one
# attempt, so the losing attempt's manifest and screenshot are already
# gone by the time this program runs.
found_at = path.parent.relative_to(root).parts
entitled_to = owning_path(record)
if found_at != entitled_to:
problems.append(
f"{path}: stored under {'/'.join(found_at) or '.'} but this attempt's "
f"identity entitles it to {'/'.join(entitled_to)}"
)
if Path(str(record["artifact_dir"])).resolve() != path.parent.resolve():
problems.append(
f"{path}: manifest claims artifact_dir {record['artifact_dir']} "
f"but was found in {path.parent}"
)
state = str(record["state"])
session_id = record["session_id"]
if state in {"running", "quit", "quit_failed"} and not session_id:
problems.append(f"{path}: state {state} has no session_id")
if state not in TERMINAL_STATES:
problems.append(f"{path}: nonterminal state after test run: {state}")
if session_id:
sessions[str(session_id)].add(owner)
for session_id, owners in sessions.items():
if len(owners) > 1:
problems.append(
f"session {session_id} was used by multiple tests: "
f"{sorted(owners)}"
)
for identity, paths in identities.items():
if len(paths) > 1:
problems.append(
f"identity {identity} was written by {len(paths)} manifests: "
f"{sorted(paths)}"
)
if problems:
for problem in problems:
print(f"ERROR {problem}")
return 1
print(
f"OK checked {len(manifest_paths)} manifests "
f"and {len(sessions)} browser sessions"
)
return 0
if __name__ == "__main__":
root = Path(sys.argv[1]) if len(sys.argv) == 2 else Path("artifacts")
raise SystemExit(main(root))The following output is illustrative. It shows the exact shape the audit program prints, not a measurement from a real suite.
python tools/audit_browser_owners.py artifacts
# Illustrative output from two deliberately conflicting manifests:
# ERROR session demo-session was used by multiple tests: [('gw2', 'tests/ui/test_login.py::test_admin_login', '91ac2f17d031'), ('gw2', 'tests/ui/test_login.py::test_anonymous_home', 'e7131a3f902b')]
# ERROR artifacts/9f2c/gw2/manifest.json: stored under 9f2c/gw2 but this attempt's identity entitles it to 9f2c/gw2/b805f9c7af16208a/8528b42be963The first line supports browser reuse because the same Selenium session ID appears under two node IDs. The second line is the directory check catching a manifest that was written to a worker-level path: two tests on gw2 shared that path, so only the last writer's manifest and screenshot are still on disk. It does not prove which cookie or storage value changed. Use the screenshots, application logs, and test actions from those same attempt directories to establish the state transition. If the session IDs are distinct, reject the shared-browser hypothesis and look elsewhere.
A backend collision has a different signature. Two failures may have different worker IDs, different session IDs, and different artifact paths, yet both application logs mention the same order ID or username. Capture the application's returned resource ID when setup creates it. Do not infer ownership from a display label if the server has a canonical identifier. When both attempts mutate one canonical ID, repair the data factory or serialize that intentionally shared scenario.
Artifact overwriting is visible even without Selenium errors. The JUnit report can list two failed node IDs while the artifact store contains one generic screenshot whose modification time is later than both tests started. Modification time alone is weak evidence on distributed filesystems, but the absence of per-attempt paths is already a design defect. The directory check reaches the same conclusion from the surviving file alone, without needing timestamps: a manifest recording a node ID and attempt ID that its own directory does not name was written somewhere more than one attempt could reach. Introduce unique paths before using image content to diagnose the product.
Grid or host capacity is the near-miss that teams most often mislabel as state sharing. If a browser cannot start, the manifest remains at state start_failed with no session ID, and pytest reports an error during fixture setup rather than a failed product assertion. Increasing workers can exceed available Grid slots, memory, process limits, or browser capacity. A function-scoped fixture can expose that limit more often because it creates sessions more frequently. Reduce workers or increase verified capacity; changing back to a shared driver trades away isolation and can conceal the infrastructure boundary.
A teardown defect has another shape. The product call can pass, then pytest reports an error while finalizing the driver fixture. The manifest shows call_outcome passed and state quit_failed. Do not file that as an application failure, and do not delete the error from the report. Check whether the test ended the session itself, the browser crashed, the remote node vanished, or networking failed before the quit command completed. The manifest narrows the phase without claiming which of those causes occurred.
Order dependence can survive fresh browsers. Test B may assume Test A created a database record. With -n0 and a fixed collection order, B passes. Under load distribution, the tests can run in different processes or the opposite order. Distinct browser sessions and unique artifacts do not repair that precondition. Make B create its own record or move the two actions into one scenario when the business requirement is genuinely sequential. Selecting loadscope or loadfile to keep tests together can reduce exposure, but it leaves the hidden dependency in place.
An ordinary synchronization defect can also correlate with worker count because a loaded host responds more slowly. Run the single failing node with xdist disabled, then run the same node repeatedly under the intended worker load while preserving server and browser versions. Inspect whether the failing assertion follows a reused owner, a shared record, or simply an unmet page condition. Adding a fixed sleep may lower failure frequency without identifying any of those causes.
Use commands that vary one boundary at a time. The paths and worker count below are examples to adapt to the suite, and the commands rely only on documented pytest-xdist options.
python -m pytest -n 0 -vv tests/ui/test_checkout.py::test_guest_checkout
python -m pytest -n 2 --dist load -vv tests/ui/test_checkout.py tests/ui/test_login.py
python -m pytest -n 4 --dist load -vv --junitxml=artifacts/junit.xml tests/ui
python tools/audit_browser_owners.py artifactsKeep the browser mode, Grid endpoint, application build, and test data policy constant while comparing -n0 with -n2. If several variables change together, a pass cannot reject the isolation hypothesis. Preserve the first failing attempt even when a retry is enabled, because the retry may receive a new worker, session, and data record.
Roll out the isolation contract without flooding CI
Changing a mature suite from one driver per worker to one driver per test can multiply session creation and reveal neglected cleanup. Roll it out as an engineering migration, not a one-line fixture edit.
First, inventory driver factories and resource scope. Search for webdriver.Chrome, webdriver.Remote, session-scoped fixtures, module globals, cached page objects, explicit profile directories, fixed download paths, and tests that call quit. Record which fixture owns each driver today. Page objects should receive a driver owned by the test; they should not retrieve a hidden singleton.
Instrument the serial run before enabling more workers. With -n0, the xdist worker_id fixture reports master, so the same manifest layout still works as long as the plugin is installed. Confirm every UI node that requests driver produces one manifest, one nonempty session ID after startup, and a terminal state. This baseline catches fixture mistakes without scheduling noise.
Next, use two workers on a narrow group whose data setup is already understood. Do not begin with -n auto. The meaning of auto depends on available CPUs, while browser capacity may be much lower. A fixed count gives CI reviewers a stable declared load. Watch setup errors, teardown errors, external record cleanup, and artifact upload separately from product assertion failures.
Move fixed accounts and filenames in small batches. Authentication tests may need purpose-built users with known roles, but those accounts should be read-only or leased with an explicit lock and release protocol. Checkout and profile-update tests mutate user state, so they usually need per-test records. Do not append worker_id to a production-like shared account and assume it is safe across simultaneous CI runs; include the run and attempt identity or use a service that leases records atomically.
The CI job should upload evidence even when pytest exits nonzero. It should also run the ownership audit after failures, because a failing test is exactly when correlation matters. This GitHub Actions example assumes requirements.txt already pins compatible pytest, pytest-xdist, and Selenium versions. It creates the artifact root before asking pytest to write JUnit XML.
name: isolated-selenium
on:
pull_request:
workflow_dispatch:
jobs:
ui:
runs-on: ubuntu-latest
timeout-minutes: 45
env:
HEADLESS: "1"
TEST_ARTIFACT_ROOT: artifacts
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.12"
cache: pip
- name: Install test dependencies
run: python -m pip install -r requirements.txt
- name: Run UI tests with a declared worker limit
run: |
mkdir -p artifacts
python -m pytest tests/ui -n 4 --dist load -vv --junitxml=artifacts/junit.xml
- name: Audit browser ownership
if: always()
run: python tools/audit_browser_owners.py artifacts
- name: Upload browser evidence
if: always()
uses: actions/upload-artifact@v7
with:
name: selenium-evidence
path: artifacts
if-no-files-found: warnThe pytest step and audit step can both fail, and GitHub Actions will still reach steps guarded by always. If no browser manifest exists because collection failed, the audit reports that absence rather than inventing an isolation verdict. The JUnit report and pytest traceback remain the sources for the collection error.
Increase worker count only after the environment can create and end sessions cleanly at the current level. Base the limit on observed capacity from your own Grid or runner, not a number copied from another team. This article does not provide illustrative throughput figures because they would not describe your pages, browser startup time, or infrastructure.
Distribution modes are tuning tools. loadscope groups functions by module and methods by class, while loadfile keeps a file on one worker. loadgroup can keep explicitly marked groups together. These modes help when an intentionally expensive fixture must be reused within a known group. They do not convert shared mutable data into isolated data. If a grouped test writes a fixed order that another group also writes, the collision remains.
Use grouping as a temporary containment measure only when the constraint is named. For example, a third-party sandbox may expose one account that cannot process concurrent mutations. Marking those tests as one xdist group can serialize that account while other tests remain parallel. The cost is less concurrency and a risk that the group becomes a dumping ground for flaky tests. Track an owner and removal condition for every such exception.
Retries belong after ownership evidence is stable. Store each attempt in its own directory and retain the first traceback. A retry that overwrites failure.png or reuses the same mutable account can erase the state needed to explain the first attempt. Passing on retry is a report outcome, not proof that the original attempt was isolated.
Accept the cost, and know when a different boundary is honest
Per-test browsers buy a strong state boundary at the price of startup time and resource use. On a local machine, the cost appears as CPU and memory pressure. On a Grid, it appears as session churn and concurrent slot demand. In a cloud browser service, it may also affect billed usage. Those are legitimate reasons to choose a smaller worker count or a narrower UI suite. They are not reasons to describe a reused session as isolated.
Some scenarios should not use parallel, per-test browsers. A single test that validates a multi-step business journey should keep one driver for the steps inside that test. Splitting its steps into separate test functions and relying on execution order makes failure reporting look granular but destroys independence. Keep the journey atomic and let the function-scoped fixture own its browser from start to finish.
A test that verifies two users interacting at the same time needs more than one driver inside one test. A factory fixture can create two drivers and register both for teardown, or the test can request role-specific fixtures. The ownership boundary is still the test, not the worker. Record both session IDs with role labels so a screenshot can be tied to the correct actor. Do not force such a scenario through a fixture that exposes only one global driver.
A deliberately persistent-profile test is another exception. Extension installation, profile migration, and browser-upgrade checks may need state to survive a restart. Give that scenario an isolated profile copy and run it in a controlled serial group. Reusing the everyday UI suite's shared profile to save setup time makes unrelated tests inherit a specialized state contract.
Read-only checks can sometimes share application data. Several tests may safely view one immutable catalog record if none can edit, reserve, delete, or trigger a server-side transition on it. Document the record as immutable and ensure the application really enforces that condition. The moment a test changes inventory, last-viewed state, access counts, or another hidden field, the resource is no longer read-only for concurrency purposes.
Do not generate unique records for a test whose purpose is to verify contention on one shared record. A stock-reservation race, optimistic-lock failure, or duplicate-submission rule needs coordinated actors touching the same target. In that case, sharing is the test input. Create the target once under the test's ownership, synchronize the actors inside that test, assert the allowed outcomes, and clean up after both actors finish. Isolating each actor onto a different order would remove the behavior under test.
A process-wide lock around every browser command is rarely an honest fix. It serializes the suite while retaining shared browser state between tests. The passing result then depends on lock order and cleanup completeness. If infrastructure supports only one browser, run one xdist worker or disable distribution for that job, keep function-scoped drivers, and state the capacity limit plainly.
Per-worker drivers can be acceptable for a disposable exploration job where tests are not independent assertions and the browser itself is the shared workflow. They can also support a purpose-built benchmark in which browser startup is intentionally outside the measured region. Do not mix those jobs with a regression suite and publish their results under the same definition of pass. Their lifecycle and reporting contracts differ.
Unique naming also has a cost. Test records accumulate when cleanup fails, artifact trees become deeper, and operators need a way to trace hashes back to node IDs. The manifest handles traceability, while explicit teardown and retention policies handle accumulation. Removing the run UID to make paths shorter transfers that cost into collision risk. Shorten the readable prefix instead.
Avoid using worker_id as a partition key that changes product behavior unless worker partitioning is itself under test. Tests should remain valid when a case moves from gw1 to gw3 or runs as master. The worker label is excellent evidence and a useful namespace component, but it is a scheduling detail, not a business identity.
Finally, do not use a clean ownership audit as an application oracle. Unique session IDs, distinct artifact paths, and successful quit calls establish that the harness respected its resource boundaries. They cannot prove a payment was captured once, an order total was correct, or an access rule held. Keep those product assertions specific enough to fail when the application changes, then use the ownership evidence to trust the conditions under which they ran.
// 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.
- 01Official pytest-xdist.readthedocs.io reference
pytest-xdist.readthedocs.io
Primary documentation selected and verified for the claims in this guide.
- 02Official pytest-xdist.readthedocs.io reference
pytest-xdist.readthedocs.io
Primary documentation selected and verified for the claims in this guide.
- 03Official pytest-xdist.readthedocs.io reference
pytest-xdist.readthedocs.io
Primary documentation selected and verified for the claims in this guide.
- 04Official docs.pytest.org reference
docs.pytest.org
Primary documentation selected and verified for the claims in this guide.
FAQ / QUICK ANSWERS
Questions testers ask
Should every pytest-xdist worker get one WebDriver?
No. A worker can execute many tests, so one driver per worker still lets those tests inherit cookies, storage, windows, and navigation state. Use a function-scoped fixture when each test needs a clean browser session.
Why does a session-scoped Selenium fixture run more than once with xdist?
Each xdist worker is a separate pytest process with its own fixture cache. A session-scoped fixture therefore runs once in every worker that requests it, not once for the distributed run as a whole.
What belongs in an artifact path for parallel browser tests?
Combine the test-run ID, worker ID, test node ID or a stable hash of it, and a unique attempt ID. That hierarchy prevents workers and retries from overwriting one another while keeping every file traceable to its owner.
How can I tell browser sharing from shared backend data?
Compare the worker ID, WebDriver session ID, test node ID, and application record ID from the same attempt. Reused session IDs across tests point toward browser reuse; distinct sessions touching the same mutable record point toward a data collision.
Is loadscope a replacement for isolated test data?
Grouping changes where tests run, not what they own. It can reduce setup cost for an intentionally shared fixture, but it cannot make a fixed username, order, or output filename safe for concurrent mutation.
RELATED GUIDES
Continue the learning route
GUIDE 01
Route External WebDriver Sessions Through a Selenium Grid Relay Node
Configure a Selenium Grid relay node to route matched sessions to an external WebDriver service with explicit capacity, health checks, and failure controls.
GUIDE 02
Instrument Selenium Commands with WebDriver Listeners
Instrument Selenium commands with WebDriverListener, structured timing events, failure screenshots, safe redaction, and useful execution timelines.
GUIDE 03
Selenium TypeScript ESM Setup for WebDriver
Learn Selenium TypeScript ESM setup with working code, failure cases, debugging steps, and CI evidence for reliable QA automation in practice.
GUIDE 04
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 05
Selenium WebDriver BiDi Interview Questions
Selenium BiDi interview questions advanced: practical design, implementation, debugging, CI, metrics, and interview guidance for QA, SDET, and automation engineers.