A flat root conftest.py that declares dozens of session-scoped, autouse=True fixtures forces pytest to evaluate them for every collected test across every package, and sibling packages that reuse a fixture name like db_session start resolving to the wrong implementation depending on collection order — surfacing as intermittent AssertionErrors in CI that vanish under -n 1. The fix is a deterministic three-tier conftest.py hierarchy with explicit pytest_plugins registration instead of cross-package imports. This guide lays out that structure and the checks that keep it from drifting.
Prerequisites
pytest >= 8.0, Python3.9+.- A
pyproject.tomlat the monorepo root definingtestpathsso collection is bounded:
# pyproject.toml (monorepo root)
[tool.pytest.ini_options]
testpaths = ["tests"]
norecursedirs = ["src", "*.egg", ".venv"]
- Optional:
pytest-xdist >= 3.0if you parallelize, which makes collision determinism mandatory.
This guide extends the resolution rules in Managing Conftest Hierarchies and the collection model from Optimizing Test Discovery.
Solution
Pytest does not use Python's import system for conftest.py discovery: it walks upward from each collected test file, registering each conftest.py as an implicit plugin in Last-In-First-Out order, so the nearest file to a test wins. Map that traversal onto three tiers.
service_client from its domain conftest, which overrides the root — while both packages pull shared fixtures from tests/shared/ through explicit pytest_plugins registration rather than cross-package imports.monorepo/
├── pyproject.toml
├── src/
│ ├── auth/
│ └── billing/
└── tests/
├── conftest.py # Root: global config + plugin registration
├── shared/
│ └── fixtures/
│ ├── db.py
│ └── network.py
├── auth/
│ ├── conftest.py # Domain: auth-specific overrides
│ └── test_sessions.py
└── billing/
├── conftest.py # Domain: billing-specific overrides
└── test_invoices.py
Root tier — session-scoped, lazily initialized, owns CLI options and shared-plugin registration:
# tests/conftest.py
import pytest
# Register shared fixtures as plugins (strings), NOT `import`:
# import would register them in the wrong collection phase and break
# request.node introspection. pytest_plugins must be a list at module top level.
pytest_plugins = ["tests.shared.fixtures.db", "tests.shared.fixtures.network"]
def pytest_addoption(parser):
"""CLI options live at the root so every package sees them."""
parser.addoption("--env", default="local",
choices=["local", "ci", "staging"],
help="Target environment for test execution")
@pytest.fixture(scope="session")
def global_config(request):
"""Session config resolver — no heavy work at import time."""
env = request.config.getoption("--env")
return {"env": env, "db_url": f"sqlite:///test_{env}.db", "mock_network": env == "ci"}
Domain tier — module-scoped, overrides root fixtures by name, enforces package boundaries:
# tests/auth/conftest.py
import pytest
from myapp.auth import create_auth_client
@pytest.fixture(scope="module")
def service_client(global_config):
"""Inherits root global_config, applies auth-specific init."""
return create_auth_client(global_config["db_url"])
@pytest.fixture(autouse=True)
def enforce_domain_boundary(request):
"""Skip cross-domain tests unless explicitly marked."""
if "cross_domain" in request.node.keywords:
pytest.skip("Cross-domain tests require @pytest.mark.cross_domain")
yield
Module tier (tests/<package>/<module>/conftest.py) is reserved for function-scoped parametrization and per-test state resets — avoid autouse=True there unless it is pure cleanup.
To catch the collision that causes the intermittent failures, scan fixture names with an AST walk (no test execution needed):
# scripts/scan_fixture_collisions.py
import ast, sys
from pathlib import Path
def scan(root: Path) -> dict[str, list[str]]:
seen: dict[str, list[str]] = {}
for conf in root.rglob("conftest.py"):
tree = ast.parse(conf.read_text())
for node in ast.walk(tree):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
for dec in node.decorator_list:
# @pytest.fixture(name="x") -> use the explicit name kwarg
if isinstance(dec, ast.Call) and getattr(dec.func, "attr", None) == "fixture":
name = next((kw.value.value for kw in dec.keywords if kw.arg == "name"), None) or node.name
seen.setdefault(name, []).append(str(conf.relative_to(root)))
# bare @fixture
elif isinstance(dec, ast.Name) and dec.id == "fixture":
seen.setdefault(node.name, []).append(str(conf.relative_to(root)))
return {k: v for k, v in seen.items() if len(v) > 1}
if __name__ == "__main__":
dups = scan(Path(sys.argv[1] if len(sys.argv) > 1 else "."))
for name, paths in dups.items():
print(f"COLLISION {name}: {', '.join(paths)}")
sys.exit(1 if dups else 0)
The collection walk is what turns a directory tree into a fixture inheritance chain.
Why this works
Pytest treats conftest.py files as directory-scoped plugins, so the upward traversal gives each package its own override layer without polluting siblings. Declaring shared fixtures through pytest_plugins (rather than import) lets pytest register them in the correct collection phase with proper hook ordering, so the fixture DAG resolver sees them as first-class fixtures and preserves the request.node introspection that a plain import would break. The AST scanner makes the one genuinely non-deterministic case — two equally-near fixtures of the same name under parallel collection — fail loudly at merge time instead of intermittently in CI.
Edge cases and failure modes
__init__.pyinside test directories. Adding one converts the folder into a regular package, so pytest importsconftest.pyas an ordinary module rather than a plugin and the hierarchy breaks. Keep test directories free of__init__.py, or usepythonpath = ["."]instead.- PEP 420 namespace packages. A parent directory without
__init__.pycan cause pytest to skip aconftest.pyduring traversal, breaking inheritance and raisingFixtureLookupErroronly in CI. Confirm the collection root withpytest --collect-only. conftest.pyundersrc/. It leaks fixtures into runtime imports and lets pytest collect production code. Move everyconftest.pytotests/and setnorecursedirs = ["src"].- Dynamic
pytest_pluginsinpytest_configure. Callingconfig.pluginmanager.register()during configuration can recurse; guard it with a flag (config._hierarchy_validated) so it runs once and never re-invokepytest.main()inside a hook. - Worker-divergent fixtures under pytest-xdist. Each worker imports modules independently, so module/session fixtures instantiate per worker — never rely on a single shared instance across workers.
Keeping service boundaries honest
The hierarchy that makes a monorepo pleasant is also the one that lets a fixture leak across a service boundary it should never cross. Three conventions keep that from happening without adding process.
Name fixtures by their owner, not by their role. A root-level db fixture will be shadowed by three different services, each meaning something different by it, and a test that ends up with the wrong one still passes — against the wrong database. billing_db and catalog_db cannot collide, and a missing name fails loudly at setup instead of resolving to a neighbour's fixture.
Put shared infrastructure in an installable package rather than at the repository root. A root conftest.py is loaded for every test in the repo, so a heavyweight import there is paid by every service on every run. A small internal package — exposed through the pytest11 entry point or imported explicitly with pytest_plugins in the services that need it — makes the dependency visible in that service's own configuration.
# services/billing/conftest.py - the dependency is explicit and local
pytest_plugins = ["testing_support.postgres", "testing_support.clock"]
# Nothing else in the monorepo pays for these imports.
Note that pytest_plugins is only honoured in a rootdir-level conftest.py on pytest 7 and later; in nested directories, register the plugin through the package's entry point instead, or import the fixtures explicitly. The error message when this bites is unambiguous, and it is better than the alternative of a root conftest that grows without limit.
Finally, assert the boundary in CI. A short test that collects each service in isolation catches accidental coupling the moment it appears:
import subprocess, sys, pathlib
def test_each_service_collects_alone():
for service in pathlib.Path("services").iterdir():
result = subprocess.run(
[sys.executable, "-m", "pytest", "--collect-only", "-q", str(service)],
capture_output=True, text=True,
)
assert result.returncode == 0, f"{service.name} cannot collect alone"
A service that only collects when the whole repository is collected has picked up a dependency on a sibling's conftest — usually an autouse fixture — and that dependency will eventually break a partial CI run. The isolation check pays for itself the first time a shared autouse fixture appears; the shape below is what it is guarding against.
The cost is easy to measure rather than guess: pytest --collect-only -q on a single service, with and without the root conftest in place, shows the import time difference directly. Anything above a few tens of milliseconds is being paid by every test file in the repository on every run, including the ones that never touch the fixture.
Treat the isolation check as a build step rather than a test when the repository grows past a dozen services: running a collection pass per service in parallel takes seconds and reports every boundary violation in one output, where a single failing test reports only the first.
Frequently Asked Questions
Can I safely share fixtures across unrelated monorepo packages?
Yes, but only through a centralized tests/shared/ directory referenced via pytest_plugins in each package's conftest.py. Avoid placing shared fixtures in the root conftest.py unless they are truly global, and use plugin registration rather than import statements so resolution stays traceable.
Why does pytest ignore my root conftest.py when running from a subdirectory?
Pytest only loads conftest.py files that are directory ancestors of the collected test files plus the rootdir's conftest. If you invoke pytest from inside a package, set rootdir explicitly or add a pyproject.toml at the monorepo root so the upward traversal reaches the shared conftest.
Why do sibling packages with the same fixture name behave non-deterministically?
When two conftest.py files in different packages define the same fixture name, the nearest one to each test wins, but parallel or glob-based collection can change which is nearest. Prefix fixtures with a domain identifier or set an explicit name= to keep resolution deterministic.
Related
- Managing Conftest Hierarchies — the resolution and override rules this layout builds on.
- Mastering Pytest Fixtures — how scope, autouse, and the fixture DAG determine setup order across tiers.
- Optimizing Test Discovery — the collection model that decides which
conftest.pyfiles pytest reaches. - pytest-xdist vs pytest-parallel Performance — why per-worker instantiation makes collision determinism mandatory.
← Back to Managing Conftest Hierarchies