PRACTICAL GUIDE / package Selenium Python automation framework
Package your Selenium framework so CI runs the code you tested
Build an installable Selenium and pytest framework that keeps imports safe, bundles test data, and proves CI runs the same code you reviewed locally.
In this guide7 sections
What you will learn
- Why the repository can hide a broken package
- Build an import-safe src layout
- Diagnose collection before blaming WebDriver
- Keep fixtures and resources installable
The checkout test passes from the repository root, then CI installs the wheel and dies during collection with ModuleNotFoundError. A second branch collects successfully but opens Chrome before pytest reports a single test. Both failures come from the framework's package boundary, not from the locator or the application under test.
Why the repository can hide a broken package
Python can import only what its module search path makes visible. That sounds elementary until three different mechanisms put different directories on that path. The interpreter considers its invocation context. An editable installation connects an environment to working source. Pytest also has import modes that affect how it imports test modules and conftest.py files. A green run therefore proves less than most teams think unless the run records where the framework was imported from.
A flat repository makes the easiest trap. Suppose academy_qa/ sits beside tests/ at the repository root. Starting pytest there allows that top-level package to be found even if the distribution configuration never includes it. The wheel can be empty of framework modules while every local test stays green. Moving importable code under src/academy_qa/ removes that accidental route. As the Python Packaging User Guide explains, a src layout normally requires installation before the package can be imported. That extra step is useful pressure. It makes a missing package declaration visible earlier.
The command used to invoke pytest matters too. According to pytest's import-path documentation, python -m pytest adds the current directory to sys.path as normal Python behavior. Pytest's default prepend mode may also insert directories while importing test modules. Neither fact makes the command wrong. The danger is treating a path-assisted run as evidence that a wheel contains the same code. Setting PYTHONPATH=src or adding pythonpath = ["src"] to pytest configuration hides the same defect more explicitly.
There are two names to keep separate. The distribution name is what an installer handles, for example academy-selenium. The import package is what test code names, for example academy_qa. Hyphens and underscores may differ, and one distribution may contain several import packages. A successful pip show academy-selenium only proves that distribution metadata is installed. It does not prove import academy_qa resolved to the intended file.
Run the following commands in the exact environment that failed. They answer different questions, so do not replace the set with one convenient command.
python -m pytest --collect-only -q
python -c 'import academy_qa; print(academy_qa.__file__)'
python -m pip show -f academy-selenium
python -m pytest --fixtures -q
python -m pytest --setup-show tests/test_web_form.py::test_form_submissionCollection should print the expected test node IDs without starting a browser. If import resolution fails, the final exception will identify the missing import, often ModuleNotFoundError: No module named 'academy_qa'. The __file__ line tells you whether Python loaded src/academy_qa, a virtual environment's site-packages directory, or some stale checkout. The file list from pip show -f reveals what the installer recorded for the distribution. Finally, --setup-show places fixture setup and teardown around one test, which is much more useful than guessing whether the driver came from a fixture.
Do not confuse a missing package with a circular import. A circular import usually resolves the package and then fails while one module is only partly initialized. Recent Python versions often mention a “partially initialized module” in that exception. The file paths in the traceback point into two or more modules that import through each other. A wheel omission, by contrast, commonly fails at the first unresolved package or subpackage. The remedies differ: package discovery fixes an omitted module, while moving shared types or reversing a dependency fixes a cycle.
Importing a module executes its top-level statements the first time that import is resolved in a process under normal import behavior. Pytest must import test modules and conftest.py during collection. If either path reaches driver = webdriver.Chrome(), browser startup has become a collection side effect. The report may show “collected 0 items” only because collection never finished. Retrying the test, increasing a wait, or changing a locator cannot repair code that ran before pytest had a test to execute.
Build an import-safe src layout
A useful package boundary separates reusable automation code from repository operations. Place page objects, driver factories, domain assertions, and packaged resources under src/academy_qa/. Keep tests under tests/. Leave screenshots, downloaded files, local credentials, generated reports, and environment-specific run configuration outside the distribution. Those artifacts have different owners and lifetimes.
The root __init__.py should be boring. Version metadata, constants without I/O, and carefully chosen lightweight exports are reasonable. Browser construction, environment validation, file reads, network calls, and logging configuration are not. An import should make definitions available. It should not mutate the machine, reserve a Grid slot, or decide which environment the eventual test will use.
A concrete tree might contain src/academy_qa/browser.py, src/academy_qa/pages/web_form.py, src/academy_qa/data/__init__.py, and src/academy_qa/data/users.json. The root src/academy_qa/__init__.py can remain empty. The data directory has its own __init__.py so it is an ordinary import package that importlib.resources can address. Tests continue to live in tests/, with shared fixtures in the nearest appropriate conftest.py.
For a new skeleton, the next shell block writes a complete pyproject.toml using setuptools. On an existing framework, merge these fields into the current build backend instead of overwriting its file. Backend-specific discovery syntax is not portable, even though the standardized [project] metadata is.
cat > pyproject.toml <<'TOML'
[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "academy-selenium"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["selenium>=4"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
"academy_qa.data" = ["*.json"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "--import-mode=importlib"
TOMLThe package finder now searches under src, not the repository root. The package-data rule includes JSON owned by academy_qa.data. It does not sweep every JSON file in the checkout into the wheel. That narrow rule reduces the chance of publishing credentials or results accidentally. It also means a new resource extension needs an intentional configuration change.
The importlib pytest mode is a useful fit for this layout because pytest does not change sys.path while importing test modules. It has a real trade-off. Test modules cannot casually import sibling test modules, and helper modules placed only under tests/ are not generally importable through that mode. Put reusable test helpers in an import package such as academy_qa.testing, or expose fixtures from conftest.py where pytest discovers them. Do not switch import modes solely to silence one error without understanding which import the old layout depended on.
A src layout does not make imports safe by itself. A module under src can still create a driver at top level, read .env during import, or import pages through a cycle. The layout removes accidental visibility. Code review and executable checks still have to protect behavior. Likewise, an editable install is not defective. It is the right tool for rapid development, provided the release path also tests a regular installation.
There is also a dependency-direction decision. Page objects may depend on Selenium interfaces and small framework utilities. Tests may depend on page objects and fixtures. The browser factory should not import test modules. A package root that re-exports every page and fixture invites cycles because importing one public name imports the entire framework. Prefer explicit imports such as from academy_qa.pages.web_form import WebFormPage. Slightly longer imports cost less than debugging a package that initializes half of itself to expose a short alias.
Diagnose collection before blaming WebDriver
Start diagnosis with a line in time: did pytest finish collection? If the answer is no, no test fixture body or product assertion can be blamed yet. Read the traceback from the first frame in your code, not only the Selenium exception at the bottom. A stack that enters academy_qa/browser.py while importing tests/conftest.py points to an import-time action. A stack that enters the driver fixture after pytest lists collected tests points to setup instead.
A team can turn that distinction into a regression test. The following test launches a fresh Python process, patches the Chrome constructor to fail, and imports the public package plus its browser module. The subprocess matters because the parent test process may already have cached those modules in sys.modules. If a future refactor calls webdriver.Chrome() during import, the subprocess returns nonzero and the assertion exposes the stderr.
# tests/test_import_safety.py
import subprocess
import sys
def test_framework_import_does_not_create_chrome_session() -> None:
probe = """
from unittest.mock import patch
with patch(
"selenium.webdriver.Chrome",
side_effect=AssertionError("Chrome was created during import"),
):
import academy_qa
import academy_qa.browser
"""
completed = subprocess.run(
[sys.executable, "-c", probe],
check=False,
capture_output=True,
text=True,
)
assert completed.returncode == 0, completed.stderrThis oracle can fail for a real code change: moving a Chrome constructor to module scope makes the patched call raise. It is intentionally narrow. If the framework also creates Firefox or Remote sessions, add probes for the constructors the package actually supports. Do not advertise this single patch as a universal detector for every possible side effect. Separate tests should cover file writes, configuration mutation, or network clients if those have caused incidents.
Once imports are inert, give driver ownership to a fixture. Selenium creates a session when a driver class is initialized, and its driver-session documentation recommends quit() to end the session rather than close(). Pytest's fixture finalization guide runs code after a yield fixture when the test finishes, including when the test assertion fails. That makes the creation and cleanup owner visible in one place.
# src/academy_qa/browser.py
import os
from selenium import webdriver
from selenium.webdriver.chrome.webdriver import WebDriver
def create_chrome() -> WebDriver:
options = webdriver.ChromeOptions()
if os.environ.get("HEADLESS") == "1":
options.add_argument("--headless")
options.add_argument("--window-size=1440,900")
return webdriver.Chrome(options=options)
# tests/conftest.py
import pytest
from selenium.webdriver.chrome.webdriver import WebDriver
from academy_qa.browser import create_chrome
@pytest.fixture
def driver() -> WebDriver:
browser = create_chrome()
try:
yield browser
finally:
browser.quit()Function scope is pytest's default, so each requesting test receives its own session here. That isolation costs browser startup time. Changing to module or session scope reduces starts, but now cookies, windows, timeouts, local storage, and application state can cross test boundaries unless the suite resets them deliberately. Shared sessions also make parallel worker ownership harder to see. Choose the wider scope only after naming the reset contract and proving it under failures, not because a slow suite makes the shortcut tempting.
The fixture keeps environment selection out of the package import path. HEADLESS is read when a test asks for a driver, not when pytest discovers a module. A malformed browser installation can still make setup fail, which is correct. In that case --collect-only remains green, while the actual test fails during fixture setup. That pair of observations distinguishes an import-safety defect from a browser-startup defect.
A page object should also avoid owning the session. It receives a driver and performs browser work only when the test calls a method. This small example uses Selenium's public locators against the web-form page used in Selenium's own documentation.
# src/academy_qa/pages/web_form.py
from selenium.webdriver.common.by import By
from selenium.webdriver.remote.webdriver import WebDriver
class WebFormPage:
def __init__(self, driver: WebDriver) -> None:
self.driver = driver
def open(self, url: str) -> None:
self.driver.get(url)
def submit_text(self, value: str) -> None:
self.driver.find_element(By.NAME, "my-text").send_keys(value)
self.driver.find_element(By.CSS_SELECTOR, "button").click()
def message(self) -> str:
return self.driver.find_element(By.ID, "message").text
# tests/test_web_form.py
import os
from academy_qa.pages.web_form import WebFormPage
def test_form_submission(driver) -> None:
target = os.environ.get(
"E2E_BASE_URL",
"https://www.selenium.dev/selenium/web/web-form.html",
)
page = WebFormPage(driver)
page.open(target)
page.submit_text("packaged framework")
assert page.message() == "Received!"That assertion is capable of failing if the page does not produce the expected state. It does not certify itself by comparing two constants inside a fixture. For a product suite, point E2E_BASE_URL to an environment your team owns and assert a business outcome specific to that application. The public Selenium page is suitable for demonstrating the package structure, not for becoming a dependency of a private release gate.
A near-miss can look identical at first. If collection succeeds and the fixture starts but Selenium cannot create a session, inspect the setup section and the constructor traceback. Browser availability, driver discovery, remote credentials, or Grid capacity may be involved. Packaging is implicated only when imports, installed files, configuration ownership, or resource inclusion disagree. The exception class alone cannot decide between those causes.
Duplicate test filenames are another near-miss. Under pytest's default path-modifying modes, standalone files with the same basename can collide in the global import namespace and produce an import-file-mismatch error. Adding __init__.py files to make tests packages, choosing unique filenames, or using importlib mode addresses that collection model. Moving the Selenium package under src does not rename colliding test modules, so inspect both the imported test path and the framework package path before changing either layout.
Keep fixtures and resources installable
The framework passes in editable mode, but the wheel crashes with FileNotFoundError: data/users.json. The JSON exists in the checkout. That fact is not evidence that it exists in the installed distribution, and the relative path says nothing about which component owns it.
Path("data/users.json") resolves from the process working directory. Local developers often run at the repository root, so an accidental path looks stable. CI may start from a job workspace, a temporary directory, or a container workdir. A wheel consumer may not have the source repository at all. Building a longer chain of parents[] calls from the test file only ties the framework to another source-tree shape.
Package runtime resources with the package that reads them. Python's importlib.resources documentation exposes resources through the import system and does not require callers to assume every package is an ordinary directory on disk. The files() API returns a traversable resource container. For JSON, direct read_text() is enough.
# src/academy_qa/data/readers.py
import json
from importlib.resources import files
from typing import Any
def load_users() -> list[dict[str, Any]]:
resource = files("academy_qa.data").joinpath("users.json")
decoded = json.loads(resource.read_text(encoding="utf-8"))
if not isinstance(decoded, list) or not decoded:
raise ValueError("users.json must contain a non-empty JSON array")
if not all(isinstance(row, dict) for row in decoded):
raise ValueError("every users.json entry must be an object")
required = {"email", "role"}
for index, row in enumerate(decoded):
missing = required.difference(row)
if missing:
names = ", ".join(sorted(missing))
raise ValueError(f"users.json entry {index} is missing: {names}")
return decoded
# tests/test_packaged_users.py
from academy_qa.data.readers import load_users
def test_packaged_user_emails_are_unique() -> None:
users = load_users()
emails = [str(user["email"]).casefold() for user in users]
assert len(emails) == len(set(emails)), "duplicate test-user email"Several independent changes can make this fail. The wheel can omit users.json, the file can contain invalid JSON, a record can lose a required field, or duplicate accounts can appear. Those are meaningful package or data-contract regressions. The code does not claim that a successfully decoded file is automatically good test data.
The package-data rule in the earlier pyproject.toml is the other half of the fix. Resource-reading code cannot recover a file the build backend never included. Inspect the built wheel in a clean environment, not merely the source distribution. An sdist and a wheel are different artifacts, and a file appearing in one does not guarantee it appears in the other.
Use importlib.resources.as_file() only when a downstream library requires a physical path. Keep all use of that path inside the context manager because an importer may materialize a temporary file and clean it when the context exits. Do not return that temporary path from a helper for later tests. APIs that accept bytes, text, or a file object are easier to use correctly with packaged resources.
Not every test input belongs in a wheel. Credentials should come from a secret store or the CI environment. Large mutable datasets may belong in object storage or a versioned fixture service. Per-run downloads belong in a temporary directory. Screenshots and reports are outputs, not resources. A small immutable schema, template, certificate used only as public test material, or canonical JSON fixture can reasonably travel with the framework. Ownership, sensitivity, and update cadence should decide placement.
An editable installation can conceal resource mistakes because it points at source files that have not been selected for the wheel. That is why “it works after pip install -e .” is only a development result. The clean wheel probe must read at least one representative resource through the same public function production tests use. Listing the archive is helpful evidence, but executing the reader catches both inclusion and access mistakes.
Prove editable and wheel installs behave the same
Editable mode optimizes feedback. A regular wheel represents what CI or another repository actually receives. A dependable framework needs both, with different jobs and different claims.
The developer loop can create a virtual environment, install the project editable, and run focused tests. That loop should stay fast. The artifact loop should build a wheel, create a separate environment, install only that wheel plus test tooling, change to a directory outside the repository, import the package, read a resource, and collect the repository's tests against the installed artifact. Reusing the development environment defeats the comparison because old editable metadata and undeclared dependencies may remain.
Save the following as ci/test-wheel.sh. It assumes the build frontend is already installed in the job that invokes it. The temporary directories are removed on exit, the stale build tree is removed before the build starts, and the clean environment receives the wheel rather than the current source tree.
#!/usr/bin/env bash
set -euo pipefail
repo_dir=$PWD
wheelhouse=$(mktemp -d)
runtime=$(mktemp -d)
cleanup() {
rm -rf "$wheelhouse" "$runtime"
}
trap cleanup EXIT
# setuptools' build_py copies sources into build/lib and never prunes it, so a
# leftover build/ or *.egg-info makes the wheel describe an earlier commit.
rm -rf "$repo_dir/build" "$repo_dir"/*.egg-info "$repo_dir"/src/*.egg-info
python -m build --wheel --outdir "$wheelhouse"
python -m venv "$runtime/venv"
"$runtime/venv/bin/python" -m pip install pytest "$wheelhouse"/*.whl
export REPO_DIR=$repo_dir
(
cd "$runtime"
PYTHONPATH= "$runtime/venv/bin/python" - <<'PY'
import os
from importlib.resources import files
from pathlib import Path
import academy_qa
origin = Path(academy_qa.__file__).resolve()
source_root = (Path(os.environ["REPO_DIR"]) / "src").resolve()
if origin == source_root or source_root in origin.parents:
raise SystemExit(f"source tree imported during wheel check: {origin}")
resource = files("academy_qa.data").joinpath("users.json")
if not resource.is_file():
raise SystemExit("users.json is missing from the installed wheel")
print(f"installed package: {origin}")
print(f"packaged resource: {resource.name}")
PY
PYTHONPATH= "$runtime/venv/bin/python" -m pytest \
--import-mode=importlib \
-q \
"$repo_dir/tests/test_import_safety.py"
PYTHONPATH= "$runtime/venv/bin/python" -m pytest \
--import-mode=importlib \
--collect-only \
-q \
"$repo_dir/tests"
)The source-origin check can fail if CI accidentally exposes src through PYTHONPATH or installs the package editable into the supposedly clean environment. The resource check can fail when the build configuration omits JSON. Collection can fail when the wheel omitted a Python subpackage or when a test depends on repository-only helper imports. None of these assertions depends on a hard-coded success fixture that the code also constructs.
The removal line above is the part most teams leave out, and leaving it out turns the whole job into theatre. Setuptools' build_py command copies package sources into build/lib and then builds the wheel from that directory. It adds and overwrites; it does not prune. A file that no longer qualifies for the distribution, because a package-data rule was deleted, a resource was renamed, or a module moved to another package, stays in build/lib from the previous build and ships anyway. Three runs against the same source tree make the behaviour concrete. With the package-data rule present and no stale tree, the wheel contains academy_qa/data/users.json. Delete the rule and remove build/, and the wheel no longer contains it, so the resource probe fails as designed. Delete the rule but leave build/ warm, and the wheel contains the file again and the job exits 0. The gate then reports success for a wheel that could not be rebuilt from the current commit, which is exactly the class of defect it exists to catch. A hosted runner with a fresh workspace hides this. A self-hosted runner, a restored cache, or any local run after a normal build does not.
Two pytest invocations appear here rather than one, and the split is deliberate. The import-safety test runs for real against the installed wheel, because --collect-only imports the test module and stops. That test does its work inside its body: it spawns a subprocess, patches the Chrome constructor, and imports the package there. Under collection alone, none of that ever executes. Add a webdriver.Chrome() call at module scope in academy_qa/browser.py and the collect-only version of this script still exits 0 with the cheerful line 1 test collected, while the version above exits 1 with AssertionError: Chrome was created during import. A test that is collected is not a test that ran.
Beyond that one file, the script stops at collection. It proves artifact importability, resource inclusion, import safety, and collectability of the wider suite. It does not prove Chrome can start or that the application works. Keep an actual browser job with product assertions as a separate gate, using the same built artifact when practical. A packaging failure and an end-to-end failure then retain separate evidence instead of collapsing into one red job.
Wire the script into CI before the browser matrix. The example below uses a supported Python version chosen by the project. Pin third-party actions according to your organization's supply-chain policy; the shown major tags are conventional moving tags, not immutable revisions.
name: Python package contract
on:
pull_request:
push:
branches:
- main
jobs:
wheel-contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Install build frontend
run: python -m pip install build
- name: Test installed wheel
run: bash ci/test-wheel.shThe extra wheel build and virtual environment add CI work. That is the direct cost of testing the deliverable instead of a convenient source checkout. Measure the job in your own pipeline before deciding whether it belongs on every pull request or on changes to Python framework paths. Do not invent a universal duration. A path filter can save capacity, but it must include pyproject.toml, package sources, tests that define the contract, resource files, and the CI script itself.
Cache design needs the same care, and it covers more than virtual environments. Caching an already populated virtual environment risks carrying an editable install or a wheel from another commit. Caching or persisting build/ and *.egg-info is worse, because those directories decide what the next wheel contains rather than just how fast it builds; that is why the script deletes them instead of trusting the workspace. Caching downloaded distributions is less likely to alter import ownership, but cache keys still need dependency inputs. The simplest trustworthy first rollout is no environment cache in the package-contract job. Optimize only after the job reports its package origin and remains reproducible.
Keep build logs as diagnostic evidence when this gate fails. The wheel filename, installation output, printed academy_qa.__file__ path, resource probe, and collection traceback answer different questions. A screenshot of a failed browser cannot tell you whether the test imported the wheel. Conversely, a green wheel probe cannot clear a locator or application defect.
Roll out the package boundary without stopping the suite
An all-at-once move creates too many explanations for one failing build. Change the boundary in stages, and make each stage reject a specific old behavior.
First, capture the current collection set with python -m pytest --collect-only -q. Store it as a migration artifact, not as an eternal golden file. Node IDs may legitimately change when test modules move, so review differences rather than forcing byte-for-byte equality. The useful question is whether a test disappeared, duplicated, or changed parametrization unexpectedly.
Next, add the build metadata and src directory while keeping behavior unchanged. Move leaf modules first: value objects, locator definitions, pure parsers, and resource readers. Then move page objects and driver factories. Update imports to the full package path as each slice moves. Leaf-first movement reduces circular imports because low-level modules do not depend on the layers being moved later.
Keep conftest.py with the tests unless a fixture truly needs distribution as a pytest plugin. Move reusable construction functions into academy_qa, then have local fixtures call them. This preserves pytest's normal fixture discovery while making the reusable behavior installable. Do not put browser fixtures in the package root merely to make imports shorter.
Temporary compatibility imports need an expiry. A forwarding module can help a large suite move from pages.login to academy_qa.pages.login, but it also preserves two public paths and can load the same concepts through confusing names. Add a warning, assign an owner, update callers in bounded batches, and delete the shim after the final consumer moves. If the old top-level module is not part of the wheel, do not let local compatibility success count as artifact parity.
Introduce the import-safety subprocess test before moving driver code. It gives the team a precise failure if a re-export later pulls session construction back into collection. Add the packaged-resource contract when the first JSON, template, or schema moves. Then run the wheel job as advisory until its missing-module and missing-resource findings are understood. Make it required only after the current suite can satisfy the boundary without path injection.
Remove masking configuration last. Search for sys.path.insert, PYTHONPATH assignments, pytest's pythonpath option, and editable installs in jobs that claim to test a wheel. Some may serve legitimate tools, so trace each caller before deleting anything. The goal is not to ban path configuration everywhere. It is to stop a release check from importing a different package than the artifact it reports as tested.
During review, ask for three paths: the imported module path, the resource path as seen through importlib.resources, and the test node ID that requested the driver fixture. Those paths tie package, data, and lifecycle evidence together without pretending they are the same failure. When parallel execution is added, also record the worker and session identifier at fixture setup and teardown, while keeping credentials out of logs.
The rollout costs attention. Absolute imports become longer. Contributors must install the project before running tests. Wheel checks consume CI capacity. Package-data rules create one more review surface. In return, the suite stops depending on an invisible launch directory, and downstream users receive an artifact that has already been exercised as an artifact. That trade is usually worthwhile for a framework shared across repositories. It may not be worthwhile for every collection of local scripts.
When packaging is not the fix
Do not turn every Selenium failure into a packaging migration. If academy_qa.__file__ points to the intended wheel, collection finishes, the driver fixture creates a session, and the failure occurs after a user action, investigate the test and product boundary. Element timing, frame or window context, browser behavior, test data, and application state remain credible causes. Rebuilding the wheel adds noise unless a module or resource differs.
A SessionNotCreatedException during fixture setup does not by itself prove a package defect. It says session creation failed, while the attached message and remote-side evidence determine why. Check whether the same artifact collects cleanly, whether the intended browser or remote endpoint is available, and whether effective options match the job's configuration. Keep the conclusion narrower than the exception name supports.
A small, single-repository test suite may not need a distributable framework. If no other project installs it and CI always runs the checked-out tests, a clear flat layout can be adequate. Imports still need to avoid side effects, fixtures still need cleanup, and paths still need an explicit owner. Do not add publishing metadata and wheel jobs only to imitate a library architecture the team does not consume.
Avoid packaging mutable environment configuration as a shortcut. Base URLs, account credentials, Grid tokens, and release-specific flags should arrive at execution time. Baking them into the wheel makes one artifact environment-specific and increases the chance of exposing secrets. Package a schema or a safe default only when it is truly part of the framework's versioned contract.
Large datasets are another poor fit. A wheel is convenient for small, immutable resources required by the code. It is not a data warehouse, artifact store, or test-result archive. If data changes independently from framework releases, give it independent versioning and retrieval controls. The test should log the dataset identity so a failure remains reproducible.
Do not choose implicit namespace packages merely to remove __init__.py files from the tree. Namespace packages solve cases where multiple distributions contribute to one import namespace. They add discovery and ownership questions that a single framework does not need. Use ordinary packages until a real multi-distribution requirement appears, then follow the chosen build backend's documented namespace configuration.
Finally, a wheel-parity gate should not replace end-to-end coverage. The gate answers whether the installed framework can be imported, can find its packaged resources, and can collect tests without browser side effects. Only a browser test against the application can answer whether the user journey works. Keep both claims in the report, and let each fail for the class of change it was built to catch.
// 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 selenium.dev reference
selenium.dev
Primary documentation selected and verified for the claims in this guide.
- 02Official docs.pytest.org reference
docs.pytest.org
Primary documentation selected and verified for the claims in this guide.
- 03Official docs.pytest.org reference
docs.pytest.org
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
Why does my Selenium package import locally but fail in CI?
Local runs often see the repository through the current working directory, an editable install, or a pytest path change. A clean wheel environment exposes modules and resources that the build left out.
Should a WebDriver session be created in __init__.py?
Keep WebDriver construction out of `__init__.py` and every other import-time path. Create the session inside a fixture, yield it to the test, and call `quit()` during fixture teardown.
How do I see which copy of my framework pytest imported?
Print the package's `__file__` value in the failing environment and compare it with `python -m pip show -f` output. A source-tree path during a wheel test means the test is not exercising the artifact you meant to validate.
Where should JSON test data live in an installable framework?
Store runtime data inside an import package, include it in the wheel configuration, and read it with `importlib.resources`. Repository-relative paths are suitable only for files that deliberately remain outside the installed package.
Is an editable install enough for package testing?
Editable installs are useful for development because source changes appear without a rebuild. They cannot replace a clean wheel test, since editable and regular installations may expose different files.
RELATED GUIDES
Continue the learning route
GUIDE 01
Java Generics for Type-Safe Selenium Frameworks
Master Java generics Selenium framework with practical examples, architecture decisions, failure analysis, CI guidance, metrics, and scenario-led interview answers.
GUIDE 02
Driver Ownership Architecture for Parallel Selenium Frameworks
Design parallel Selenium workers with one explicit WebDriver owner, isolated test data and artifacts, same-thread access, and guaranteed deterministic teardown.
GUIDE 03
Selenium Framework Modernization Interview Questions
Selenium modernization interview questions: practical design, implementation, debugging, CI, metrics, and interview guidance for QA, SDET, and automation engineers.
GUIDE 04
Advanced Java Automation Framework Interview Questions
advanced Java automation interview questions: practical design, implementation, debugging, CI, metrics, and interview guidance for QA, SDET, and automation engineers.
GUIDE 05
Debug Java Classpath Conflicts in Selenium Frameworks
A practical guide to debug Java Selenium classpath conflicts, covering design, implementation, debugging, scale, measurable release gates, and senior interview scenarios.