Pytest & CI

Creating conftest.py Hierarchies for Monorepos

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, Python 3.9+.
  • A pyproject.toml at the monorepo root defining testpaths so collection is bounded:
TOML
# pyproject.toml (monorepo root)
[tool.pytest.ini_options]
testpaths = ["tests"]
norecursedirs = ["src", "*.egg", ".venv"]
  • Optional: pytest-xdist >= 3.0 if 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.

Plain text
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:

Python
# 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:

Python
# 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):

Python
# 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__.py inside test directories. Adding one converts the folder into a regular package, so pytest imports conftest.py as an ordinary module rather than a plugin and the hierarchy breaks. Keep test directories free of __init__.py, or use pythonpath = ["."] instead.
  • PEP 420 namespace packages. A parent directory without __init__.py can cause pytest to skip a conftest.py during traversal, breaking inheritance and raising FixtureLookupError only in CI. Confirm the collection root with pytest --collect-only.
  • conftest.py under src/. It leaks fixtures into runtime imports and lets pytest collect production code. Move every conftest.py to tests/ and set norecursedirs = ["src"].
  • Dynamic pytest_plugins in pytest_configure. Calling config.pluginmanager.register() during configuration can recurse; guard it with a flag (config._hierarchy_validated) so it runs once and never re-invoke pytest.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