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.
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)
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, preserving 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.
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.
← Back to Managing Conftest Hierarchies