Writing Pytest Conftest Files That Don't Become a Mess
Every pytest project starts with a single conftest.py at the root, a handful of fixtures, and the best of intentions. A few months later that same file is 600 lines long, nobody is sure which fixtures are still used, and adding a new one feels like defusing a bomb. I've seen this pattern repeat on teams of every size, and the root cause is almost always the same: conftest files grow by accretion rather than by design.
The good news is that pytest's fixture system is genuinely well-designed — it gives you scoping, layered conftest files, and factory patterns that can keep things clean for the long haul. The problem is that none of those features save you automatically. You have to make deliberate choices early, and you have to revisit them as the suite grows. This article is about those choices: where to put fixtures, how to scope them, when to split a conftest, and what warning signs to watch for before things get out of hand.
If you're also working across both Behave and pytest in the same organisation, the structural thinking here overlaps — deciding which tool owns which layer of your tests is a prerequisite to knowing which conftest should own which fixture. But even in a pure pytest shop, the principles below apply directly.
Learn Node.js, Cucumber, GitHub Copilot, APIs, CI/CD, and modern automation by building a complete framework.
Layered Conftest Files: Stop Putting Everything at the Root
The single most impactful structural decision you can make is to stop treating conftest.py as a monolith. Pytest resolves fixtures by walking up the directory tree from the test file, which means you can — and should — have multiple conftest files at different levels of your project.
Here's a layout that holds up in practice:
tests/
conftest.py ← session-scoped, global fixtures only
api/
conftest.py ← API-specific fixtures (base URLs, auth clients)
test_orders.py
test_products.py
integration/
conftest.py ← integration-specific fixtures (DB connections, seeds)
test_checkout.py
The root conftest owns exactly what every test in the project needs: maybe a configured httpx or requests session, environment config loading, or a shared logger. The subdirectory conftests own what's specific to that domain. A fixture defined in tests/api/conftest.py is invisible to tests/integration/ tests — that isolation is a feature, not a limitation.
A mistake I see constantly is dumping API client fixtures, database seed helpers, mock server setup, and environment loaders all into one root conftest because "they might be needed anywhere." In practice, most of them are only ever used in one subdirectory. Move them down. If a test in another subdirectory genuinely needs one later, you can promote it at that point — that's a deliberate decision, not an accident.
The rule of thumb: a fixture belongs at the lowest level where it's shared, not the highest level where it might theoretically be needed.
Fixture Scope Is a Contract — Treat It Like One
Scope is where most conftest debt originates. When a fixture is scoped too broadly, tests start sharing state they shouldn't. When it's scoped too narrowly, you pay setup costs on every single test and your suite slows down for no reason. Getting this right requires thinking about what the fixture actually represents.
Here's a concrete breakdown of how I think about scope selection:
scope="session"— Use for things that are expensive to create and genuinely stateless from a test's perspective: a compiled JWT, a base HTTP client configured from environment variables, a read-only reference dataset loaded from a file. If two tests can corrupt this object by running in sequence, it does not belong at session scope.scope="module"— Useful for grouping setup that's specific to one test file: spinning up a mock server for a particular endpoint family, or seeding a database table that a whole module reads from. Less commonly needed than people think.scope="function"(the default) — Use for anything that writes, mutates, or produces side effects. If your fixture creates a resource, the function scope ensures it's torn down before the next test can see stale state.
The practical mistake is marking a fixture session because it feels expensive, then realising later that it holds mutable state. A good test for this: if you ran your tests in a different order, would the fixture behave identically? If the answer is "probably," that's not good enough.
Also worth noting: scope mismatches produce pytest warnings that are easy to ignore. Don't. A function-scoped fixture that depends on a session-scoped fixture is fine. The reverse — a session-scoped fixture requesting a function-scoped one — is an error. Pytest will tell you. Listen to it.
When you're working across a larger framework that mixes BDD-style setup with pytest fixtures, scope discipline becomes even more critical because the fixture lifecycle has to stay predictable across different test entry points.
Splitting, Importing, and Knowing When a Conftest Has Outgrown Itself
Even with good layering and correct scoping, a conftest can still grow unwieldy if you never prune it. The signal to watch for isn't just line count — it's cohesion. When a single conftest contains fixtures for HTTP clients, database connections, mock servers, test data factories, and environment bootstrapping, it has stopped being a configuration file and become a utility module with a conftest wrapper.
The fix is straightforward: move fixture implementations into regular Python modules and import them into conftest. Here's the pattern:
# tests/fixtures/api_clients.py
import pytest
import httpx
@pytest.fixture(scope="session")
def api_client(base_url):
with httpx.Client(base_url=base_url, timeout=10.0) as client:
yield client
# tests/conftest.py
from fixtures.api_clients import api_client # noqa: F401 — pytest needs the import
from fixtures.db_helpers import db_session # noqa: F401
The # noqa: F401 comment matters — linters will flag these as unused imports because they don't see pytest's fixture resolution magic. Suppressing the warning explicitly signals to the next developer that this import is intentional.
A few other patterns that prevent conftest sprawl in practice:
- Factory fixtures over data fixtures. Instead of a separate fixture for every test data variant, write one factory fixture that accepts parameters.
make_order(status="pending")is more maintainable thanpending_order,confirmed_order, andshipped_orderas three separate fixtures. - Audit unused fixtures periodically. Run
pytest --collect-onlyand grep for fixture names. If a fixture hasn't been requested by any test in months, it's either dead code or a sign that tests have drifted away from using shared setup properly. - Document scope and intent inline. A one-line docstring on a fixture — "Session-scoped. Creates one authenticated client per test run. Do not mutate." — is worth more than a comment three files away.
The same discipline that keeps step definitions reusable in Behave applies here: fixtures are shared infrastructure, and shared infrastructure needs a clear contract. When a fixture is easy to find, easy to understand, and scoped to exactly the right level, adding a new test feels like snapping a block into place rather than untangling a knot.
The goal isn't a perfectly minimalist conftest — it's a conftest where every fixture has an obvious reason to exist at that level of the hierarchy. If you can look at your root conftest and explain in one sentence why each fixture lives there rather than in a subdirectory, you're in good shape.