PRACTICAL GUIDE / Selenium Python production test framework

Build a Selenium Python framework that survives CI failures

Structure Selenium and pytest with validated config, isolated driver fixtures, explicit waits, useful failure artifacts, and reliable CI cleanup.

By The Testing AcademyUpdated August 4, 202622 min read
All field guides
In this guide7 sections
  1. Give every layer one kind of responsibility
  2. Parse configuration once and own the driver in a fixture
  3. Put waits beside the application state they describe
  4. Capture failure evidence before the fixture quits
  5. Diagnose the failure phase before changing the framework
  6. Roll the framework into CI without hiding dependencies
  7. Know when not to add another framework layer

What you will learn

  • Give every layer one kind of responsibility
  • Parse configuration once and own the driver in a fixture
  • Put waits beside the application state they describe
  • Capture failure evidence before the fixture quits

The checkout test is green locally, but CI fails before the first assertion and leaves no screenshot. A module-scoped driver was already on the wrong page, configuration came from three different environment helpers, and teardown never ran after setup failed. The framework made the product failure harder to see.

A production test framework is not a large base class or a folder named utils. It is a set of small ownership rules that survive failure: configuration is parsed once, each test gets isolated browser state, waits describe application conditions, artifacts belong to one attempt, and cleanup runs from the fixture that created the session.

Give every layer one kind of responsibility

Start with boundaries, not inheritance. Pytest owns collection, fixture setup, test calls, teardown, and reports. A configuration module turns external strings into validated settings. A driver factory translates those settings into fresh Selenium options and creates one session. Page or component objects provide vocabulary for the interface. Tests own business assertions. Artifact code records enough state to investigate a failed attempt.

When those responsibilities collapse into BaseTest, every change has a surprising blast radius. A login method silently navigates in setup. A screenshot method assumes a driver field exists. A browser switch changes global state. Subclasses override teardown and forget super(). The class looks centralized, but ownership is implicit and error paths are difficult to trace.

Pytest fixtures already model dependencies and cleanup. A function-scoped driver fixture creates a session for one test and quits it after that test. A page object can depend on the driver without owning its lifecycle. A session-scoped settings fixture is safe because its value is frozen and contains no browser. Scope follows state: stable process configuration can live for the session; mutable browser state should start at function scope unless reuse is an explicit, tested policy.

Configuration must stop reading the environment after validation. If a page object calls os.getenv("BASE_URL") while the driver fixture reads pytestconfig, two parts of one attempt can disagree. Build one Settings object at session start and inject it. Log a sanitized projection once so the report shows which browser, transport, and timeout policy the run selected.

Keep test data out of global fixtures. A shared user, order number, download directory, or email address creates collisions even when every test has its own browser. Data factories should create unique records and register their cleanup. A browser fixture cannot isolate state held by the application server.

The driver factory should not know page locators, accounts, or expected messages. Its successful outcome is a usable session configured according to validated settings. The first product assertion belongs after navigation. This separation tells you whether a failure occurred before session creation, during application setup, or at the behavior under test.

Artifact capture is observability, not the assertion oracle. A screenshot can show the wrong page and still cannot decide whether an account should have been locked. The test should assert a domain result. The artifact helps a person explain why that assertion failed.

Parse configuration once and own the driver in a fixture

The following conftest.py defines user-facing pytest options, validates URLs and numeric bounds, creates browser-specific Selenium options, and yields a function-scoped driver. The flags are not invented Selenium APIs; they are project options registered through pytest's parser. This is the first version of the file; a later section replaces it with one that also captures failure evidence.

Settings is frozen so test code cannot reassign its fields. Its strings are immutable, so shallow freezing is sufficient for this shape. The remote URL remains optional. When it is absent, the factory creates a local driver. When it is present, webdriver.Remote receives a concrete options instance for the selected browser.

Python
# conftest.py
from __future__ import annotations

from dataclasses import dataclass
import os
from pathlib import Path
from typing import Literal
from urllib.parse import urlsplit

import pytest
from selenium import webdriver
from selenium.webdriver.remote.webdriver import WebDriver


BrowserName = Literal["chrome", "firefox"]


@dataclass(frozen=True)
class Settings:
    browser: BrowserName
    base_url: str
    remote_url: str | None
    headless: bool
    wait_seconds: float


def pytest_addoption(parser: pytest.Parser) -> None:
    group = parser.getgroup("browser")
    group.addoption("--browser", choices=("chrome", "firefox"), default="chrome")
    group.addoption("--base-url", default=os.getenv("BASE_URL"))
    group.addoption("--remote-url", default=os.getenv("SELENIUM_REMOTE_URL"))
    group.addoption("--headed", action="store_true", default=False)
    group.addoption("--wait-seconds", type=float, default=10.0)


def checked_http_url(value: str | None, name: str, *, required: bool) -> str | None:
    if value is None or value.strip() == "":
        if required:
            raise pytest.UsageError(f"{name} is required")
        return None

    normalized = value.strip().rstrip("/")
    parsed = urlsplit(normalized)
    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
        raise pytest.UsageError(f"{name} must be an absolute http(s) URL")
    return normalized


@pytest.fixture(scope="session")
def settings(pytestconfig: pytest.Config) -> Settings:
    wait_seconds = pytestconfig.getoption("--wait-seconds")
    if wait_seconds <= 0 or wait_seconds > 120:
        raise pytest.UsageError("--wait-seconds must be greater than 0 and at most 120")

    return Settings(
        browser=pytestconfig.getoption("--browser"),
        base_url=checked_http_url(
            pytestconfig.getoption("--base-url"), "--base-url", required=True
        ),
        remote_url=checked_http_url(
            pytestconfig.getoption("--remote-url"), "--remote-url", required=False
        ),
        headless=not pytestconfig.getoption("--headed"),
        wait_seconds=wait_seconds,
    )


def create_driver(settings: Settings) -> WebDriver:
    if settings.browser == "chrome":
        options = webdriver.ChromeOptions()
        if settings.headless:
            options.add_argument("--headless=new")
        options.add_argument("--window-size=1440,900")
        return (
            webdriver.Remote(command_executor=settings.remote_url, options=options)
            if settings.remote_url
            else webdriver.Chrome(options=options)
        )

    options = webdriver.FirefoxOptions()
    if settings.headless:
        options.add_argument("-headless")
    return (
        webdriver.Remote(command_executor=settings.remote_url, options=options)
        if settings.remote_url
        else webdriver.Firefox(options=options)
    )


@pytest.fixture
def driver(settings: Settings) -> WebDriver:
    browser = create_driver(settings)
    try:
        yield browser
    finally:
        browser.quit()

The fixture uses yield, so code after the yield runs during teardown. Pytest documents that if a yield fixture raises before reaching yield, its own post-yield code does not run. That is correct here when WebDriver construction itself fails because there is no returned browser to quit. If create_driver() allocates another resource before a later setup step, put that resource behind its own fixture or a local try block so a partial setup has a cleanup owner.

create_driver() performs only construction. Post-construction commands belong inside the fixture's protected region, as the instrumented version later in this article demonstrates for Firefox window sizing. That distinction matters because a command can fail after a session exists. The created driver must already have a cleanup owner before that command runs.

Chrome's launch argument and Firefox's runtime resize are not identical mechanisms, so evidence should record the actual returned window size if viewport matters to a test. Do not infer it solely from configuration. A suite that needs exact cross-browser sizing can apply set_window_size() to both branches after construction and accept the extra protocol command.

Function scope costs browser startup time. That cost buys isolation of cookies, windows, timeouts, downloads, and navigation. If startup dominates the suite, measure it before changing scope. Reusing one session requires a reset contract that closes extra windows, clears relevant storage, restores timeouts, resets downloads, and proves the application account state is clean. driver.delete_all_cookies() alone is not a complete reset for modern applications.

Put waits beside the application state they describe

Navigation readiness is not application readiness. Selenium's waiting guidance notes that a document ready state can be reached while JavaScript continues changing the page. A fixed sleep guesses how long those changes take. An explicit wait asks for the condition the next action requires and returns as soon as it is satisfied.

Page objects should keep locators and interaction sequences together, but tests should retain business assertions. This login page waits until inputs can be used, submits credentials, and exposes the visible error text. It does not decide which error is correct for a product rule.

Python
# pages/login_page.py
from __future__ import annotations

from urllib.parse import urljoin

from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait


class LoginPage:
    EMAIL = (By.CSS_SELECTOR, "[data-testid='login-email']")
    PASSWORD = (By.CSS_SELECTOR, "[data-testid='login-password']")
    SUBMIT = (By.CSS_SELECTOR, "[data-testid='login-submit']")
    ERROR = (By.CSS_SELECTOR, "[role='alert']")

    def __init__(self, driver: WebDriver, base_url: str, timeout: float) -> None:
        self._driver = driver
        self._url = urljoin(base_url + "/", "login")
        self._wait = WebDriverWait(driver, timeout)

    def open(self) -> None:
        self._driver.get(self._url)
        self._wait.until(EC.visibility_of_element_located(self.EMAIL))

    def submit_credentials(self, email: str, password: str) -> None:
        email_input = self._wait.until(EC.element_to_be_clickable(self.EMAIL))
        password_input = self._wait.until(EC.element_to_be_clickable(self.PASSWORD))
        email_input.clear()
        email_input.send_keys(email)
        password_input.clear()
        password_input.send_keys(password)
        self._wait.until(EC.element_to_be_clickable(self.SUBMIT)).click()

    def visible_error(self) -> str:
        return self._wait.until(EC.visibility_of_element_located(self.ERROR)).text
Python
# tests/e2e/test_login.py
from pages.login_page import LoginPage


def test_rejects_an_invalid_password(driver, settings) -> None:
    page = LoginPage(driver, settings.base_url, settings.wait_seconds)
    page.open()

    page.submit_credentials("known-user@example.test", "definitely-wrong")

    assert page.visible_error() == "Email or password is incorrect"

The assertion can fail when the application returns a different message, never renders an alert, or incorrectly authenticates the user. It does not compare a value to the same fixture used to generate it. In a real environment, the known user should come from a test-data fixture with explicit creation and cleanup rather than a shared permanent account.

Choose expected conditions based on the next operation. Presence is enough when code only needs an element reference for later inspection. Visibility is needed when the user should see it. Clickability is a convenience condition for visible and enabled state, but it cannot prove another element is not covering the target or that the application will accept the click. If clicks still fail, inspect layout, overlays, and product behavior rather than increasing every timeout.

Keep implicit wait at its default when the framework standardizes on explicit waits. Selenium warns that mixing implicit and explicit waits can produce unpredictable total wait times. A hidden implicitly_wait() call in base setup affects every subsequent element lookup in the session, including lookups inside an explicit wait. Search for it during migration and remove it deliberately.

Timeouts are policy, not performance claims. A local run and a loaded remote browser can need different bounds, but a larger number does not repair a condition that can never become true. When a timeout occurs, the expected condition in the stack trace tells you what the framework was waiting for. Pair that with a screenshot and sanitized URL, then check whether the locator matched nothing, the element stayed hidden, or the application never reached the state.

Avoid wrapping every page method in try/except Exception to take a screenshot. That duplicates artifact logic, can catch assertion failures at inconsistent layers, and often rethrows a new generic exception that loses Selenium's original stack. Let one fixture-level mechanism observe the pytest call report while the driver is still live.

Capture failure evidence before the fixture quits

Pytest creates separate reports for setup, call, and teardown. A browser screenshot is most useful for a call-phase failure after the driver fixture yielded. A driver-construction failure has no browser to capture. A teardown failure may occur while quitting, when a screenshot command is no longer reliable. Treat those phases differently rather than promising an image for every failure.

The artifact machinery is worth keeping in its own module, because none of it needs pytest's collection machinery and all of it is worth unit testing without a browser. The first file below is framework/artifacts.py. It is a complete file with its own imports, not a fragment to paste into conftest.py.

Python
# framework/artifacts.py
from __future__ import annotations

import json
import logging
import os
import re
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit, urlunsplit
from uuid import uuid4

import pytest
from selenium.webdriver.remote.webdriver import WebDriver


reports_key = pytest.StashKey[dict[str, Any]]()


def safe_case_name(nodeid: str) -> str:
    return re.sub(r"[^A-Za-z0-9_.-]+", "_", nodeid).strip("_")


def url_without_query_or_credentials(raw_url: str) -> str:
    parts = urlsplit(raw_url)
    hostname = parts.hostname or ""
    if ":" in hostname:
        hostname = f"[{hostname}]"
    host = f"{hostname}:{parts.port}" if parts.port else hostname
    return urlunsplit((parts.scheme, host, parts.path, "", ""))


def resolve_artifact_root() -> Path:
    root = Path(os.environ.get("TEST_ARTIFACT_DIR", "artifacts"))
    root.mkdir(parents=True, exist_ok=True)
    return root


def capture_failure(
    browser: WebDriver, artifact_root: Path, nodeid: str
) -> None:
    case_dir = artifact_root / f"{safe_case_name(nodeid)}-{uuid4().hex}"
    case_dir.mkdir(parents=True, exist_ok=False)

    screenshot_path = case_dir / "failure.png"
    if not browser.save_screenshot(str(screenshot_path)):
        logging.warning("WebDriver returned false while saving %s", screenshot_path)

    capabilities = browser.capabilities
    metadata = {
        "nodeid": nodeid,
        "sessionId": browser.session_id,
        "url": url_without_query_or_credentials(browser.current_url),
        "title": browser.title,
        "browserName": capabilities.get("browserName"),
        "browserVersion": capabilities.get("browserVersion"),
        "platformName": capabilities.get("platformName"),
    }
    (case_dir / "session.json").write_text(
        json.dumps(metadata, indent=2, sort_keys=True), encoding="utf-8"
    )

The report hook and the fixtures have to stay in conftest.py, because pytest only registers pytest_runtest_makereport from a conftest or an installed plugin, and a fixture defined in an ordinary imported module is never collected. The hook stores reports in item.stash, the collision-safe storage pytest provides for plugins and hooks. The driver fixture reads the call report after the test body completes, captures a PNG and a small JSON record, then quits. Artifact errors are logged and suppressed so they do not replace the test failure. A quit() error is allowed to surface as a teardown error because leaked sessions are operational failures worth reporting.

The listing below is the finished conftest.py in full. Save it over the first version rather than appending it, because both files open with from __future__ import annotations and Python accepts that import only at the top of a file. Pasting one after the other raises SyntaxError: from __future__ imports must occur at the beginning of the file before a single test is collected.

Python
# conftest.py
from __future__ import annotations

from dataclasses import dataclass
import logging
import os
from pathlib import Path
from typing import Literal
from urllib.parse import urlsplit

import pytest
from selenium import webdriver
from selenium.webdriver.remote.webdriver import WebDriver

from framework.artifacts import capture_failure, reports_key, resolve_artifact_root


BrowserName = Literal["chrome", "firefox"]


@dataclass(frozen=True)
class Settings:
    browser: BrowserName
    base_url: str
    remote_url: str | None
    headless: bool
    wait_seconds: float


def pytest_addoption(parser: pytest.Parser) -> None:
    group = parser.getgroup("browser")
    group.addoption("--browser", choices=("chrome", "firefox"), default="chrome")
    group.addoption("--base-url", default=os.getenv("BASE_URL"))
    group.addoption("--remote-url", default=os.getenv("SELENIUM_REMOTE_URL"))
    group.addoption("--headed", action="store_true", default=False)
    group.addoption("--wait-seconds", type=float, default=10.0)


def checked_http_url(value: str | None, name: str, *, required: bool) -> str | None:
    if value is None or value.strip() == "":
        if required:
            raise pytest.UsageError(f"{name} is required")
        return None

    normalized = value.strip().rstrip("/")
    parsed = urlsplit(normalized)
    if parsed.scheme not in {"http", "https"} or not parsed.netloc:
        raise pytest.UsageError(f"{name} must be an absolute http(s) URL")
    return normalized


@pytest.fixture(scope="session")
def settings(pytestconfig: pytest.Config) -> Settings:
    wait_seconds = pytestconfig.getoption("--wait-seconds")
    if wait_seconds <= 0 or wait_seconds > 120:
        raise pytest.UsageError("--wait-seconds must be greater than 0 and at most 120")

    return Settings(
        browser=pytestconfig.getoption("--browser"),
        base_url=checked_http_url(
            pytestconfig.getoption("--base-url"), "--base-url", required=True
        ),
        remote_url=checked_http_url(
            pytestconfig.getoption("--remote-url"), "--remote-url", required=False
        ),
        headless=not pytestconfig.getoption("--headed"),
        wait_seconds=wait_seconds,
    )


def create_driver(settings: Settings) -> WebDriver:
    if settings.browser == "chrome":
        options = webdriver.ChromeOptions()
        if settings.headless:
            options.add_argument("--headless=new")
        options.add_argument("--window-size=1440,900")
        return (
            webdriver.Remote(command_executor=settings.remote_url, options=options)
            if settings.remote_url
            else webdriver.Chrome(options=options)
        )

    options = webdriver.FirefoxOptions()
    if settings.headless:
        options.add_argument("-headless")
    return (
        webdriver.Remote(command_executor=settings.remote_url, options=options)
        if settings.remote_url
        else webdriver.Firefox(options=options)
    )


@pytest.hookimpl(wrapper=True)
def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo):
    report = yield
    item.stash.setdefault(reports_key, {})[report.when] = report
    return report


@pytest.fixture
def artifact_root() -> Path:
    return resolve_artifact_root()


@pytest.fixture
def driver(settings: Settings, request: pytest.FixtureRequest, artifact_root: Path):
    browser = create_driver(settings)
    try:
        if settings.browser == "firefox":
            browser.set_window_size(1440, 900)
        yield browser
    finally:
        call_report = request.node.stash.get(reports_key, {}).get("call")
        if call_report is not None and call_report.failed:
            try:
                capture_failure(browser, artifact_root, request.node.nodeid)
            except Exception:
                logging.exception("Could not capture browser failure artifacts")
        browser.quit()

The finished file defines one driver fixture, and the instrumented version replaces the simple one shown earlier rather than living beside it. The resize now sits inside the protected region, so a failure proceeds through the finally block and quits the created Firefox session. Because framework/artifacts.py imports nothing from conftest.py, the dependency runs one way only and the helpers stay importable from a plain unit test.

The code intentionally omits page source, cookies, browser logs, request bodies, and the full capability dictionary. Those artifacts can contain form data, tokens, internal hosts, proxy credentials, or provider metadata. Add them only with a field-level redaction design and a retention policy. More evidence is not automatically safer evidence.

The URL helper removes user information by rebuilding the authority from hostname and port, then drops query and fragment. That protects common token-bearing URLs. It does not inspect sensitive identifiers embedded in the path. If your application puts secrets or personal data there, replace path segments according to the application's route schema before writing the JSON.

The random suffix avoids collisions when the same node ID is retried or run by parallel workers. Randomness here names an artifact directory; it is not a measurement or a test oracle. Reports should use the pytest node ID and CI attempt metadata for correlation, not try to infer order from the random value.

Setup failures still need evidence. Log driver.create.started with sanitized settings before construction and driver.create.completed with session ID and returned standard capabilities after it succeeds. When the first event has no completion, inspect the constructor exception, Selenium service logs, Grid status, browser process logs, and network reachability. A missing screenshot in that case is expected because no usable session reached the fixture.

Diagnose the failure phase before changing the framework

A local-only pass and remote-only failure often starts before the product. Compare the sanitized settings record, whether a remote URL was selected, and the returned capabilities if a session exists. A local driver may discover a browser installed on the runner, while Grid uses a browser on its node. A binary path, filesystem permission, or browser version on one machine says nothing about the other.

When the session starts and open() times out waiting for the email field, inspect the screenshot and safe URL. If the URL is the login route and the page shows a loading shell, the application condition never arrived. If the URL is an identity-provider redirect, authentication routing changed. If the URL is correct but a visible email input uses a different test ID, the locator contract changed. All three can produce a timeout from the same page method, but their evidence differs.

A NoSuchElementException before an explicit wait is a framework smell. Search for direct find_element() calls in setup and page methods. It may also be legitimate when the test is explicitly asserting absence and uses the exception as part of that check. Do not replace every direct lookup mechanically. Decide whether the next operation requires waiting or whether immediate absence is the behavior under test.

An assertion failure with a complete screenshot is later than a locator timeout. In the login example, an alert could be visible with unexpected wording. The test reached the product outcome and disagreed with it. Fix the expected value only after checking the requirement; do not bury the mismatch by changing visible_error() to accept any nonempty text.

Teardown errors have their own report phase. If quit() fails after a passing call, pytest should report a teardown error. Check whether the remote session had already disappeared, the endpoint became unreachable, or code called quit() somewhere else. Selenium documents invalid session errors when commands address a deleted session. Avoid page objects that close or quit the shared fixture behind pytest's back.

Parallel failures often belong to data, not drivers. Separate function-scoped sessions can still update the same account, consume the same one-time link, or write the same download filename. Compare session IDs first. If they differ and each stays within one test, examine application identifiers and filesystem paths. Adding another WebDriver fixture layer will not isolate a shared backend row.

Artifact absence also has several causes. No screenshot is expected when driver construction failed. A screenshot command can fail because the session died. The artifact directory can be unwritable. The upload step can be skipped or point at the wrong path. Record capture errors in the test log and make the CI upload step run with always(). Do not convert “no image” directly into “pytest hook did not run.”

Retries complicate every conclusion. Each attempt needs its own directory, session ID, settings projection, and pytest report. A passing retry does not make the first attempt's call report pass. Keep attempt-level outcomes in the final reporter so flakiness remains visible rather than being flattened into one green test.

Roll the framework into CI without hiding dependencies

Move in slices. First centralize configuration and reject malformed values. Then introduce a function-scoped driver fixture for one smoke test. Add explicit waits to the page objects used by that test. Add call-phase artifacts only after the lifecycle is reliable. Finally, expand coverage and concurrency. Changing every test, wait, and report hook at once makes failures impossible to attribute.

Keep dependencies locked. Selenium, pytest, browser images, and any parallel or retry plugins can change behavior across versions. A pull request that upgrades one of them should run the same contract smoke tests and be reviewable as a dependency change. Do not let CI install unconstrained latest versions while the container image remains pinned.

The workflow below assumes a locked requirements.txt, the custom options defined above, and a staging base URL stored as a repository variable. It waits for the Selenium service through the service health check, runs Chrome and Firefox independently, writes JUnit output into the same artifact root, and uploads evidence even when pytest fails.

YAML
name: selenium-python-contract
on: [pull_request]

jobs:
  browser-smoke:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        browser: [chrome, firefox]
    services:
      selenium:
        image: selenium/standalone-${{ matrix.browser }}:4.44.0-20260505
        ports:
          - 4444:4444
        options: >-
          --shm-size=2g
          --health-cmd "/opt/bin/check-grid.sh --host 0.0.0.0 --port 4444"
          --health-interval 5s
          --health-timeout 3s
          --health-retries 20
    env:
      BASE_URL: ${{ vars.E2E_BASE_URL }}
      SELENIUM_REMOTE_URL: http://localhost:4444
      TEST_ARTIFACT_DIR: artifacts/${{ matrix.browser }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
          cache: pip
      - run: python -m pip install -r requirements.txt
      - run: >-
          python -m pytest -q tests/e2e
          --browser=${{ matrix.browser }}
          --base-url="$BASE_URL"
          --remote-url="$SELENIUM_REMOTE_URL"
          --junitxml="artifacts/${{ matrix.browser }}/junit.xml"
      - if: always()
        uses: actions/upload-artifact@v4
        with:
          name: selenium-${{ matrix.browser }}-evidence
          path: artifacts/${{ matrix.browser }}/
          if-no-files-found: warn

The shell quoting in a folded YAML command deserves attention. Quotes become part of shell syntax, not part of the option value, so URLs with query characters are passed as one argument. The framework then strips queries from its own diagnostics. Prefer a base URL without credentials or secrets even though the diagnostic helper redacts common cases.

Run unit tests for settings validation, URL redaction, safe artifact names, and page-object helpers without Grid. A test for URL redaction should supply a URL containing user information, query, and fragment, then assert none appear in output. That oracle fails if a future refactor accidentally logs the raw URL. Keep one browser smoke per supported branch to prove the binding and endpoint work together.

Scale parallelism only after one-worker evidence is trustworthy. Cap worker count to available browser slots and runner resources. If pytest-xdist is introduced, remember that session-scoped fixtures are scoped to a worker process rather than magically shared as one global value. Function-scoped drivers still provide the clearest isolation, and unique artifact directories prevent workers from overwriting one another.

Production readiness has costs. Function-scoped sessions add startup latency and provider usage. Explicit waits require teams to name real application states. Redaction requires maintenance as routes and artifacts change. Locked images require dependency updates. Those costs purchase failures that can be classified and reproduced, which is more valuable than a fast suite whose green result includes hidden retries and leaked state.

Define an exit criterion for the migration. Direct driver construction outside conftest.py should disappear from the migrated package, old base-class setup should have no remaining subclasses, and every browser test should write its result under one node ID. These are repository facts the team can audit. They are better than declaring the framework “production ready” because a chosen number of runs happened to pass.

Review the unhappy paths in a canary job before expanding the suite. Use one temporary test that fails a real DOM assertion to verify screenshot capture, one invalid URL invocation to verify usage errors happen before browser allocation, and one controlled teardown fault in a test double to verify cleanup errors remain visible. Keep these as framework contract tests or remove the intentionally failing canary after inspection. Never leave a permanently failing product test in the normal gate and teach CI to ignore it.

Ownership should remain obvious in logs. The settings fixture owns parsing, the driver fixture owns the session, the page object owns interaction vocabulary, and the test owns the expected outcome. When an incident cannot be assigned to one of those boundaries, add evidence at the handoff before adding another abstraction.

Know when not to add another framework layer

A small suite with five direct tests may need only validated settings, one driver fixture, and two page components. A plugin registry, dependency-injection container, service locator, and universal base page would add navigation overhead for the team without solving a demonstrated problem. Extract a layer when at least two callers share a stable contract.

Do not wrap Selenium methods one for one. A helper named click(locator) that calls driver.find_element(*locator).click() hides no domain concept and often makes stack traces less direct. A method named submit_credentials() earns its place because it groups a user action and the waits needed to perform it.

Avoid a session-scoped browser for tests that mutate authentication, storage, windows, downloads, locale, permissions, or application data. Reuse is an optimization that needs a reset oracle. If the team cannot state and test the clean state between cases, the session is not reusable.

Do not put assertions inside every page method. A page component may assert its own structural contract when construction cannot proceed, but product expectations belong in tests. Keeping assert visible_error() == ... in the test makes the requirement readable and lets another test expect a different valid message.

Skip automatic retries until first-attempt evidence is complete. Retries increase browser load and can move the attempt to another Grid node, another application instance, or a different data state. If policy requires them, report every attempt and keep artifacts separate. Never call a retried pass simply “stable.”

Finally, do not collect artifacts with no retention or privacy decision. Page source and screenshots can contain customer-like data even in test environments. Capture the smallest useful set, redact known sensitive routes and fields, limit access, and expire artifacts according to policy. A production framework is responsible not only for finding failures, but also for handling the evidence it creates.

// 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.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 4, 2026

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.

  1. 01
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official selenium.dev reference

    selenium.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official developer.mozilla.org reference

    developer.mozilla.org

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

What belongs in conftest.py for a Selenium pytest suite?

Runner integration belongs there: command-line options, validated session settings, driver fixtures, report hooks, and artifact plumbing. Page behavior and business assertions belong in normal modules that can be imported and tested directly.

Should Selenium WebDriver use a session-scoped pytest fixture?

Function scope is the safer default because cookies, windows, timeouts, and application state cannot cross tests. Session reuse can reduce startup cost, but it needs an explicit reset contract and gives up isolation.

How can pytest take a screenshot only when a browser test fails?

Store the call-phase TestReport from pytest_runtest_makereport, then inspect it while the driver fixture is tearing down but before quit(). Treat screenshot errors as artifact failures so they do not replace the original assertion.

Why are explicit waits better than sleep in Selenium Python?

Explicit waits poll for the application state a step actually needs and stop as soon as that condition is true. A fixed sleep either wastes time or remains too short when CI is slower.

How should Selenium tests run in parallel with pytest?

Give each test its own driver, unique data, and collision-free artifact path, then cap workers to available browser capacity. Parallel workers do not repair shared accounts, shared downloads, or session-scoped mutable fixtures.