The Anatomy of a pytest Suite
A pytest test looks deceptively simple - a function name starting with test_, an assert statement, done - but nearly every non-obvious pytest decision traces back to mechanics hiding underneath that simplicity: how fixtures actually get "found" and injected, why collection can fail before a single test runs, and why the fixture scope you pick is really a trade-off between isolation and setup cost.
pytest Setup gets a project running with real fixtures and configuration; the tool-specific pages that follow - Fixtures, Parametrization, Mocking & Patching, Property-Based Testing - each go deep on one mechanism. This page stays one level up: what a test actually is under pytest's model, how fixtures form a dependency graph, and how the pieces relate to each other regardless of which specific mechanism a given test happens to use.
Summary
- pytest resolves a test's dependencies through injection by parameter name, building a graph of fixtures behind the scenes rather than requiring explicit setup/teardown wiring.
- Insight: Understanding fixture resolution and scope explains almost every confusing pytest failure - a fixture "not found," unexpected shared state between tests, or a suite that passes locally but fails in a different collection order.
- Key Concepts: collection, fixture, scope, dependency injection, parametrization, test double.
- When to Use This Model: Diagnosing a fixture that isn't being found or is shared unexpectedly, deciding between a mock and a real dependency, choosing fixture scope for an expensive resource, and explaining why an import error breaks the whole suite instead of one test.
- Limitations/Trade-offs: pytest's implicit, name-based fixture resolution is powerful but non-obvious - a large fixture graph across many
conftest.pyfiles can be genuinely hard to trace without tooling (pytest --fixtures), and no amount of green tests proves the untested paths are correct. - Related Topics: the testing pyramid (unit vs. integration), test doubles and mocking boundaries, continuous integration gates, property-based testing.
Foundations
Before pytest runs anything, it performs collection: walking the test directories, importing every file matching its discovery pattern (test_*.py or *_test.py), and gathering every function whose name starts with test_ into a list of test items. This is why a syntax error or a bad import in a test file breaks the entire collection step rather than failing one test - the file has to import successfully before pytest can even see what's inside it.
A fixture is a function decorated with @pytest.fixture that provides a test's dependency - a database connection, sample data, an API client - and pytest injects it purely by matching parameter names: a test function that declares def test_x(sample_user): receives whatever sample_user fixture is visible in scope, with no import or explicit wiring required. That name-based, implicit resolution is pytest's signature mechanic, and it is also the source of most "why isn't my fixture being used" confusion, since a typo in the parameter name silently produces a "fixture not found" error rather than a quiet no-op.
A useful analogy: think of fixtures as a dependency injection container that resolves by name instead of by type - request db_session as a parameter, and pytest walks up through conftest.py files (the current directory, then parent directories) to find the nearest fixture with that exact name, the same way a DI framework in another language resolves a constructor argument to a registered provider.
import pytest
@pytest.fixture
def sample_user():
return {"id": 1, "email": "ada@example.com"}
def test_user_email(sample_user): # injected by parameter name
assert "@" in sample_user["email"]Mechanics & Interactions
Fixture scope determines how long a fixture's value lives before pytest tears it down and creates a fresh one, and picking a scope is really choosing a point on a trade-off between isolation and setup cost. A function-scoped fixture (the default) is recreated for every single test, maximizing isolation at the cost of repeating setup work; a session-scoped fixture is created once for the entire test run, minimizing cost but requiring the developer to guarantee nothing mutates shared state across tests that rely on it. A database engine is the textbook session-scoped resource (expensive to create, safe to share because it holds no per-test state itself), while the transaction wrapping each test typically stays function-scoped specifically so one test's writes never leak into the next.
Fixtures compose by simply requesting each other as parameters, which means the "dependency graph" language is not just a metaphor - a fixture that depends on another fixture is a real edge in a real graph pytest resolves before running the test, and pytest enforces one hard constraint on that graph: a broader-scoped fixture cannot depend on a narrower-scoped one, because a session-scoped fixture created once has no way to receive a fresh function-scoped value on every test.
Test doubles - stand-ins for real dependencies - interact with this same fixture graph rather than replacing it: a monkeypatch fixture or a unittest.mock.patch call substitutes one node in the dependency graph (a network client, a clock, an environment variable) so a test can isolate the logic it actually cares about. Choosing a double versus a real dependency is a smaller version of the same isolation-versus-realism trade-off scope decisions make: a mocked HTTP client runs in microseconds but can't catch a real serialization bug, while a real client against a test server catches that bug at the cost of slower, more complex setup.
@pytest.fixture(scope="session")
def engine():
eng = create_engine("sqlite:///:memory:")
yield eng # teardown runs after the session ends
eng.dispose()
@pytest.fixture
def db_session(engine): # depends on the session-scoped fixture
conn = engine.connect()
txn = conn.begin()
yield Session(bind=conn)
txn.rollback() # function-scoped: isolates every testAdvanced Considerations & Applications
Parametrization (@pytest.mark.parametrize) and property-based testing (Hypothesis) sit on the same underlying spectrum rather than being unrelated features: both run one test body against many inputs, but parametrization enumerates a fixed, developer-chosen list of cases, while Hypothesis generates hundreds of inputs from a declared strategy and shrinks any failure down to the smallest reproducing example. Parametrization is the right tool when the edge cases are already known (a boundary value, a known-bad string); Hypothesis earns its cost when the input domain is large enough that a human enumerating cases by hand would predictably miss the one that actually breaks the function.
pytest's plugin architecture is what turns a fairly small core (collection, fixtures, assertion rewriting) into an ecosystem covering async code, coverage, and web API testing without bloating the base tool: pytest-asyncio teaches the runner to await coroutine test functions, pytest-cov wraps coverage.py to report which lines executed, and each plugin hooks into the same collection and fixture machinery rather than inventing a parallel one. That architecture is also why mixing plugin versions carelessly can produce confusing failures - a plugin hooking into fixture setup can change behavior for fixtures it never directly touches.
Coverage deserves the same scrutiny here it gets in any testing discussion: it reports the percentage of lines or branches pytest executed during the run, which is a completely different claim from what was meaningfully verified. A test that calls a function and asserts nothing about its result still counts as covering every line that function executed while proving nothing - coverage is useful for finding code nobody exercises at all, and a poor target to chase for its own sake.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
| Fixtures (function scope) | Maximum isolation, no leaked state | Setup cost paid on every test | Default choice for most test dependencies |
| Fixtures (session/module scope) | Amortizes expensive setup once | Requires care that nothing mutates shared state | DB engines, app instances, anything expensive and stateless-to-share |
parametrize | Explicit, readable, fast for known cases | Misses edge cases nobody thought to enumerate | Boundary values, known-bad inputs, small matrices |
| Hypothesis (property-based) | Finds edge cases no one enumerated, shrinks failures automatically | Slower, requires framing invariants instead of examples | Parsers, serializers, math/roundtrip invariants |
Common Misconceptions
- "Fixtures are just setup/teardown helpers, like
setUp/tearDown." They're resolved through a name-based dependency graph pytest builds and injects automatically - closer to dependency injection than to imperative setup code. - "A
session-scoped fixture is always faster and therefore always better." It amortizes cost across the whole run, but only safely when nothing about it mutates between tests - misapplied to something with per-test state, it silently breaks isolation instead of saving time. - "More parametrized cases always means better coverage of edge cases." Enumerated cases only cover what a human thought to write down; a large input domain often has an edge case parametrization never considered, which is exactly the gap Hypothesis is designed to close.
- "100% coverage means the suite is thorough." Coverage counts lines executed, not assertions that meaningfully checked behavior - a test can execute every line and verify nothing.
- "An import error in one test file only fails that file's tests." Collection has to successfully import a file before pytest can see any test inside it, so a broken import can prevent pytest from even discovering tests that would otherwise pass.
FAQs
How does pytest actually decide which functions are tests?
During collection, it imports every file matching its discovery pattern (typically test_*.py) and gathers every function whose name starts with test_ - this happens before any test body executes.
How does a fixture get "injected" into a test function?
By matching the test function's parameter name to a fixture of the same name visible in scope - pytest walks up through conftest.py files to find the nearest match, with no explicit import required.
What's the practical difference between fixture scopes?
Scope controls how long a fixture's value lives before being recreated - function scope recreates it per test for maximum isolation, while session scope creates it once for the whole run to amortize expensive setup, at the cost of needing to guarantee it stays safe to share.
Why can't a session-scoped fixture depend on a function-scoped one?
Because the session fixture is created once and would have no way to receive a fresh value from a fixture meant to be recreated for every single test - pytest enforces that a broader scope cannot depend on a narrower one.
What's the difference between `yield` and `return` in a fixture?
yield lets code after it run as teardown once the test (or scope) finishes; return provides a value with no teardown phase at all.
When should I reach for `parametrize` versus Hypothesis?
Use parametrize when the edge cases are already known and small in number; reach for Hypothesis when the input domain is large enough that a human enumerating cases by hand would likely miss the one that actually breaks the function.
What does Hypothesis's "shrinking" actually do?
When a generated input causes a failure, Hypothesis searches for the smallest input that still reproduces it, turning a confusing large failing example into a minimal, readable one.
Is mocking a dependency always safer than using the real thing?
Not necessarily - a mock isolates a test from a real dependency's cost and flakiness but also can't catch a bug that only the real dependency's behavior would expose, such as an actual SQL syntax error.
Why does an import error in a test file cause more failures than expected?
Collection has to successfully import a file before pytest can identify any tests inside it, so one broken import can prevent discovery of every test in that file, not just the one near the broken line.
What is pytest's plugin architecture actually providing?
A shared set of hooks into collection, fixtures, and reporting that plugins like pytest-asyncio or pytest-cov extend, rather than each plugin implementing its own parallel test-running machinery.
Should I aim for a specific coverage percentage?
Not directly as a target - coverage tells you what code executed, not what was meaningfully asserted, so it is more useful for finding completely untested code than for judging how well-tested the tested code actually is.
Why does pytest recommend `conftest.py` instead of importing fixtures directly?
conftest.py files are auto-discovered at each directory level, so fixtures defined there become available to every test in that directory and below without an explicit import, which is what makes the name-based resolution graph work across a whole test tree.
Related
- pytest Setup - project layout, discovery, and configuration in practice
- Fixtures - scopes, factories, and composing the dependency graph
- Parametrization - enumerated data-driven tests
- Mocking & Patching - substituting nodes in the fixture graph
- Property-Based Testing - generated inputs and shrinking
- Coverage & Reporting - what coverage measures and its limits
Stack versions: This page was written for Python 3.14.0, FastAPI 0.115+, Django 5.2, Flask 3.1, Pydantic 2, PyTorch 2.6+, pandas 2.2+, Polars 1.x, ruff 0.9+, and uv 0.6+.