A root conftest.py starts as three shared fixtures and ends as four hundred lines imported by every test in the repository. Every run pays for every import in it, unrelated suites inherit fixtures they never asked for, and the file becomes a merge-conflict magnet. Fixtures do not have to live in conftest files: any importable module can be registered as a plugin, which gives the same visibility with an explicit, per-project dependency.
Prerequisites
- pytest 7.0+ — note the rootdir restriction on
pytest_pluginsintroduced in 7.0. - The resolution order described in managing conftest hierarchies.
Solution
Write the fixtures in an ordinary module. Nothing about it is special — no conftest.py name, no directory placement rules:
# testing_support/postgres.py
import pytest
@pytest.fixture(scope="session")
def pg_dsn(worker_id):
# worker_id comes from pytest-xdist; "master" when running serially.
return f"postgresql:///test_{worker_id}"
@pytest.fixture
def pg_session(pg_dsn):
conn = connect(pg_dsn)
tx = conn.begin()
yield Session(bind=conn)
tx.rollback() # every test starts from the same state
conn.close()
Register it where the fixtures are needed. Inside one repository the simplest route is pytest_plugins in the rootdir conftest:
# conftest.py at the rootdir — the only place pytest_plugins is honoured
pytest_plugins = [
"testing_support.postgres",
"testing_support.clock",
]
For a monorepo where different services need different support modules, an installed plugin is the better fit, because registration then follows the dependency rather than the directory. The same module gains a pytest11 entry point and each service depends on it explicitly — the mechanics are in packaging a pytest plugin with entry points.
The third route needs no packaging at all: -p on the command line, or in addopts for one project:
# services/billing/pyproject.toml
[tool.pytest.ini_options]
addopts = "-p testing_support.postgres -p testing_support.clock"
That form is explicit, local to the project that needs it, and visible in the file a new engineer reads first — which makes it the best default for a monorepo where the support package is not separately versioned.
Why this works
pytest treats a registered plugin module exactly like a conftest for the purposes of fixture collection: it scans the module for functions decorated with @pytest.fixture and adds them to the fixture registry under the names they declare. The difference is when and whether. Conftest files are discovered by directory walk and are mandatory for everything beneath them; plugin modules are registered explicitly and only where declared. Because plugins register before conftest files are loaded, a conftest fixture of the same name shadows the plugin's — the local definition always wins, which is what makes overriding a shared fixture in one directory possible.
Keeping the support package cheap
The reason to move fixtures out of a root conftest is cost, so it is worth checking that the move actually removed it.
Import time is the measurable part. A root conftest importing SQLAlchemy, boto3 and pandas adds that import cost to every pytest invocation in the repository, including --collect-only and including suites that never touch a database. Measure it directly:
$ python -X importtime -c "import testing_support.postgres" 2>&1 | tail -5
import time: 1943 | 35128 | sqlalchemy
import time: 412 | 2201 | testing_support.postgres
Anything above a few tens of milliseconds is worth deferring. Two techniques do that without changing the fixture API: import the heavy dependency inside the fixture body rather than at module level, and split one support module into several so a project registering testing_support.clock does not pay for testing_support.postgres.
# testing_support/postgres.py — heavy import deferred to first use
import pytest
@pytest.fixture(scope="session")
def pg_engine(pg_dsn):
from sqlalchemy import create_engine # paid only when the fixture is used
engine = create_engine(pg_dsn)
yield engine
engine.dispose()
The second habit is to keep the modules small and single-purpose. A support package of six focused modules lets each project register exactly what it uses, and it makes the dependency legible: reading addopts tells you what a service's tests actually need, which a four-hundred-line root conftest never did.
Migrating an overgrown root conftest
The move from one large conftest to registered modules is mechanical, and doing it in the order below keeps the suite green at every step.
Step one: split by consumer, not by theme. Group the fixtures by which directories actually request them, using --fixtures-per-test on a sample of tests from each area rather than by reading the file. Fixtures used by one service move to a module named for that service; fixtures used by everything stay where they are for now.
$ pytest services/billing --fixtures-per-test -q | grep conftest.py | sort -u
pg_session -- conftest.py:44
frozen_clock -- conftest.py:71
Step two: move the code, keep the conftest importing it. Cut the fixtures into testing_support/postgres.py and add pytest_plugins = ["testing_support.postgres"] to the same root conftest. Nothing about visibility changes, so the whole suite still passes — this step is a pure refactor and is the one to land on its own.
Step three: narrow the registration. Remove the module from the root pytest_plugins and add -p testing_support.postgres to the addopts of each project that needs it. Now the failure mode is loud: a service that used the fixture without declaring it fails at setup with "fixture not found", which is precisely the coupling the migration is meant to surface.
Step four: delete what nothing requests. Fixtures that no project registered are dead code that was invisible while the root conftest loaded everything. Removing them is the actual payoff, and it is usually a third of the file.
Do not attempt steps two and three in one commit. The refactor is safe and the narrowing is not, and separating them means a failing service can be fixed — or given back its registration — without reverting the move.
Edge cases and failure modes
pytest_pluginsin a nested conftest is an error since pytest 7. The message is explicit, but code moved from an older repository hits it immediately; convert those to-pin the project config or to an installed plugin.- Plugin modules must be importable from the rootdir. A
srclayout without an installed package means the module is not onsys.pathduring collection; install the support package (even editable) rather than manipulatingsys.pathin a conftest. - Fixtures in a plugin cannot see conftest-only fixtures. Registration order means a plugin fixture requesting a name defined only in a deep conftest resolves at use time, so it works in one directory and fails in another. Keep plugin fixtures self-contained.
- Autouse fixtures in a shared plugin apply everywhere it is registered. That is the same repository-wide cost the root conftest had, just relocated — make autouse a deliberate choice per module.
--fixturesreports the defining file, which is the fastest way to confirm a fixture is coming from the plugin rather than from a stale conftest copy left behind by the migration.
One organisational note for teams doing this at scale: give the support package an owner and a changelog. Shared fixtures are production code for the test suite, and a breaking change to pg_session affects every service that registered it — which is exactly the coupling the migration made visible. A one-line changelog entry per behavioural change, plus a deprecation period for renamed fixtures, costs almost nothing and prevents the failure mode where a support-package upgrade breaks four services at once and each team debugs it separately.
There is one visibility difference worth internalising before choosing this route: a fixture in a conftest is discoverable by reading the directory, while a fixture in a registered plugin is discoverable only by reading the registration. That trade is usually worth it — explicit dependencies beat ambient ones — but it puts weight on pytest --fixtures, which becomes the canonical answer to "what is available here". Teach the command alongside the migration, and consider adding a short docstring to every shared fixture, because --fixtures prints them and a one-line description is the difference between a discoverable API and a list of names.
Frequently Asked Questions
Can I import fixtures directly from another module? Importing the fixture function works but is fragile: the name must be re-exported in the importing module's namespace, and pytest resolves it there rather than at the definition site. Registering the module as a plugin is the supported route.
Where is pytest_plugins allowed?
Since pytest 7, pytest_plugins is only honoured in the rootdir-level conftest.py. In nested conftest files it raises an error, so use an installed plugin or the -p flag for directory-specific registration.
Do plugin-provided fixtures override conftest ones? No, the opposite. Plugins register before conftest files, so a fixture with the same name defined in a conftest shadows the plugin's version for that directory tree.
Related
- Managing conftest hierarchies — the resolution order this builds on.
- Creating conftest.py hierarchies for monorepos — service boundaries and the isolation check.
- Packaging a pytest plugin with entry points — turning the support module into a real dependency.
- Fixing ScopeMismatch errors in pytest — the error that appears when shared fixtures are widened during a migration.
← Back to Managing Conftest Hierarchies