Intermediate 5 min · March 06, 2026

Pytest Fixtures: Mutable Session Fixtures Cause Flaky Tests

Tests pass individually but fail randomly due to mutable session fixtures.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
Quick Answer
  • Pytest fixtures are dependency injection for tests: you declare what you need, pytest provides it
  • Fixtures control lifecycle: setup runs before, teardown runs after (even on failure)
  • Scope parameter: function (safe, slow) to session (fast, risky)
  • conftest.py makes fixtures available across files without imports
  • Performance tip: widen scope only for expensive immutable resources (DB schemas, servers)
  • Biggest mistake: using session scope with mutable objects — one test corrupts state for all others

If you've been writing tests where the first 15 lines of every test function look identical — creating objects, opening connections, seeding data — you're solving the same problem pytest fixtures were built to eliminate. Most tutorials show you the syntax and move on, but they skip the critical insight: fixtures aren't just about convenience, they're about reliability. When your test setup lives in one place, you fix it in one place. When it's copy-pasted across 40 test functions, a single database schema change breaks 40 things in 40 slightly different ways.

The deeper problem fixtures solve is isolation with reuse. Tests need a clean, predictable environment. But spinning up that environment on every single test is either slow or wasteful. Fixtures let you say 'set this up once per test, once per module, or once for the entire test session' — and pytest handles the lifecycle automatically, including teardown, even if a test crashes halfway through.

By the end of this article you'll understand how to write fixtures that handle setup and teardown cleanly, how scope controls when fixtures are created and destroyed, how to compose fixtures from other fixtures, and how to spot the two or three mistakes that silently corrupt test results. You'll walk away able to restructure a messy test suite into something a team can actually maintain.

What a Fixture Actually Is — and Why a Plain Function Won't Cut It

Before fixtures existed, developers used setup_method and teardown_method on test classes — a rigid, class-bound pattern inherited from JUnit. The moment you needed shared setup across different test files or wanted to compose two pieces of setup together, you were stuck.

A pytest fixture is a function decorated with @pytest.fixture that pytest injects into your test functions automatically when you list its name as a parameter. That word — inject — is the key. You don't call the fixture yourself. You declare a dependency, and pytest resolves it. This is dependency injection, the same pattern used in Angular, Spring, and FastAPI.

Why does that matter? Because pytest can now control the lifecycle. It knows when to call your fixture, what to pass to tests that depend on it, and crucially, when to run cleanup code after the test finishes — even if the test raised an exception. A regular helper function called inside a test gives you none of that safety.

The other reason to reach for fixtures over plain helper functions: fixtures are composable. A fixture can depend on other fixtures, letting you build complex environments from small, testable pieces rather than one monolithic setup blob.

Teardown Without Try/Finally — Using yield to Clean Up Safely

Setup is easy. Teardown is where tests get fragile. The classic mistake is putting cleanup code after your test assertions — if an assertion fails and raises an exception, the cleanup never runs. You end up with leaked database rows, unclosed file handles, or ports still bound after your test suite finishes.

Pytest solves this elegantly with yield inside a fixture. Everything before yield is setup. The yielded value is what gets injected into the test. Everything after yield is teardown — and pytest guarantees it runs regardless of whether the test passed, failed, or exploded with an unexpected exception.

This is functionally equivalent to wrapping everything in a try/finally block, but it reads like a story: 'here's what I'm setting up, here's the resource, here's how I clean it up.' That clarity matters enormously in a large test suite where a new team member needs to understand what each fixture owns.

A critical mental model: think of yield fixtures as context managers. In fact, if you've written with open(...) as f: you already understand the concept — pytest is just handling the __enter__ and __exit__ for you behind the scenes.

Fixture Scope — Controlling When Setup Runs to Speed Up Your Test Suite

By default, a fixture runs fresh for every single test that requests it. That's correct for lightweight objects like dictionaries. But spinning up a real database, launching a test server, or loading a 500MB machine-learning model before every test function will make your suite painfully slow.

Pytest's scope parameter lets you declare the lifetime of a fixture: function (default), class, module, package, or session. A session-scoped fixture is created once when the test run starts and torn down when it finishes. A module-scoped fixture lives for one test file.

The tradeoff is isolation versus speed. Wider scope means faster tests but risks state leaking between tests — if test A modifies the shared object and test B relies on it being clean, you have an order-dependent test suite, which is one of the most frustrating bugs to diagnose.

The golden rule: use the narrowest scope that doesn't make your suite unbearably slow. Start with function. Only widen scope for genuinely expensive operations — database schema creation, server startup, auth token generation. Never widen scope to share mutable state.

conftest.py — Sharing Fixtures Across Files Without Importing Anything

Here's something that confuses almost every developer the first time: how do you share a fixture between multiple test files? You don't import it. Pytest has a special file called conftest.py that it discovers automatically. Any fixture defined in conftest.py is available to every test file in the same directory and all subdirectories — no import required.

This isn't magic, it's pytest's plugin system working quietly. When pytest collects tests, it walks up the directory tree looking for conftest.py files and registers their fixtures. Your test files can then list those fixture names as parameters and pytest resolves them.

Where you place conftest.py determines scope of availability. A conftest.py at the project root makes fixtures available everywhere. One inside a specific subdirectory makes fixtures available only to tests in that directory. This lets you have authentication fixtures available globally, but payment-specific fixtures only available to the payments test directory.

This is the primary pattern for organising fixtures in real projects — not piling everything into test files.

Fixture Parametrization and Composition — Building Complex Scenarios Without Duplication

Sometimes you need the same test to run with different configurations: different user roles, different database states, different API versions. You could write a separate test function for each case — but that's duplication. Pytest provides two tools to handle this: fixture parametrization and fixture composition.

Fixture parametrization uses the params argument to @pytest.fixture. When you supply params, the fixture runs once for each parameter value. The test that uses that fixture runs once per parameter — you get combinatorial coverage without writing loops or multiple test functions.

Fixture composition is the ability to combine multiple small, focused fixtures into a larger test scenario. Instead of one fixture that creates a user, logs them in, and gives them permissions, you create three independent fixtures (user, logged_in_user, admin_user) and compose them in tests as needed. This gives you flexibility: one test can use just user, another can use logged_in_user, and a third can use admin_user without any duplication.

Together, these two patterns let you build what looks like a complex test matrix from small, reusable pieces. Your test suite stays lean, and when a requirement changes, you edit the fixture — not every test.

Fixture Scope Comparison
FixturesCreated / DestroyedBest ForIsolation Risk
function (default)Before / after each testMutable objects — dicts, lists, model instancesNone — each test is fully isolated
classOnce per test classRelated tests in a class that share lightweight stateLow — scoped to one class
moduleOnce per .py test fileDB schema setup, file parsing, module-level configMedium — all tests in file share state
packageOnce per package directoryIntegration test suites with expensive shared infraMedium-High — broad sharing
sessionOnce per entire test runServer startup, auth tokens, ML model loadingHigh — any mutation affects all tests

Key Takeaways

  • Fixtures are injected by parameter name — you declare what you need, pytest builds and delivers it. You never call a fixture function directly.
  • Use yield instead of return whenever your fixture opens any resource. Code after yield is guaranteed teardown — it runs even if the test crashes.
  • Scope controls cost vs isolation: function scope is safest for mutable data; session scope is fastest but dangerous if any test mutates the shared object.
  • conftest.py is the single correct place for shared fixtures — pytest discovers it automatically and makes its fixtures available to all tests in the same directory tree without any imports.
  • Parametrize and compose fixtures to avoid test duplication. Each fixture should set up exactly one piece of state.

Common Mistakes to Avoid

  • Sharing mutable state with a wide-scoped fixture
    Symptom: Tests pass when run individually but fail in random order when run together. The failure depends on which test modified the shared object first. `pytest --random-order` reveals the order dependency.
    Fix: Make wide-scoped fixtures return immutable data: tuples, frozensets, namedtuples, or frozen dataclasses. If you must share mutable data, use a function-scoped fixture that deep-copies from a session-scoped source using copy.deepcopy().
  • Putting teardown code after a return statement instead of using yield
    Symptom: Database connections, temp files, and open sockets are never cleaned up. Second test run fails with 'Address already in use' or 'database is locked'. Resource leaks accumulate until the suite crashes.
    Fix: Replace return with yield. Move all cleanup logic to after the yield statement. Pytest guarantees that code after yield runs even if the test raises an exception.
  • Defining fixtures inside test files and wondering why other test files can't see them
    Symptom: 'PytestUnknownHook' or 'fixture not found' error when running tests from a different file. The fixture exists in `test_something.py` but `test_other.py` can't see it.
    Fix: Move shared fixtures to a conftest.py file in the appropriate directory. Pytest auto-discovers fixtures from conftest.py files. No imports needed — just list the fixture name as a parameter in any test under that directory tree.

Interview Questions on This Topic

  • QWhat is the difference between a pytest fixture with function scope and one with session scope — and when would choosing the wrong scope cause your tests to become order-dependent?SeniorReveal
    A function-scoped fixture runs fresh for every test that requests it — setup before each test, teardown after. A session-scoped fixture runs once for the entire test run. The wrong scope causes order-dependency when the session fixture returns a mutable object and one test modifies it. Subsequent tests see the modified state, so test A passing or failing changes the result for test B. To fix it, use immutable return types for wide scopes, or narrow the scope to function and accept the performance cost.
  • QHow does pytest's conftest.py differ from a regular Python module of helper functions, and why does pytest not require you to import fixtures from conftest.py?Mid-levelReveal
    conftest.py is automatically discovered by pytest during test collection. It's part of pytest's plugin system: when pytest walks the directory tree, it registers all fixtures defined in any conftest.py it finds. Regular Python modules require explicit imports. conftest.py's fixtures are registered by name in pytest's internal fixture registry — when a test parameter matches a fixture name, pytest resolves it without needing an import statement. This is dependency injection at the framework level.
  • QIf a session-scoped fixture depends on a function-scoped fixture, what error does pytest raise and why — and how would you restructure the fixtures to fix it without losing the session-level performance benefit?SeniorReveal
    Pytest raises a ScopeMismatchError because a wider-scoped fixture (session) cannot depend on a narrower-scoped fixture (function). The reason: the session fixture is created once and lives across many tests, but the function fixture is created fresh for each test. The session fixture would get a stale reference that gets cleaned up after the first test. To fix, either widen the inner fixture's scope to at least match the outer one (making the function fixture session-scoped), or invert the dependency: make the session fixture independent and pass its value down via composition in the test itself. Usually the correct fix is to make both session-scoped if they're truly expensive.

Frequently Asked Questions

Can a pytest fixture call another fixture?

Yes — and this is one of the most powerful patterns in pytest. Simply add the other fixture's name as a parameter to your fixture function, exactly as you would in a test. Pytest resolves the full dependency graph automatically. The only constraint is scope: a fixture cannot depend on a fixture with a narrower scope than its own.

What is the difference between pytest fixtures and setUp/tearDown in unittest?

unittest's setUp and tearDown are tied to test classes and run for every method in that class — you can't easily share them across files or compose them. Pytest fixtures are standalone functions that any test can request by name, can be scoped to function/class/module/session, and compose naturally by listing other fixtures as parameters. Fixtures are more granular, more reusable, and handle teardown more safely via yield.

Why does my fixture run multiple times when I only expected it to run once?

The default scope is function, which means the fixture runs fresh for every test that requests it. If you want it to run only once per test file, add scope='module' to the @pytest.fixture decorator. For once per entire test session, use scope='session'. Check the scope first whenever you see unexpected multiple executions.

Can I use a fixture inside another fixture without making it a parameter?

No — the only way to access another fixture's value is to list it as a parameter. Just like a test function, a fixture function's parameters are resolved by pytest. If you try to call the fixture function directly, you'll get a generator object, not the fixture's return value. Always use parameter injection.

How do I debug why a fixture is not being used by a test?

Use pytest --fixtures to see all available fixtures. Use pytest --fixtures-per-test to see exactly which fixtures each test uses. If a fixture isn't listed, check that it's defined in a conftest.py that covers the test file and that the parameter name matches exactly. Also check for typos or missing underscores.

🔥

That's Python Libraries. Mark it forged?

5 min read · try the examples if you haven't

Previous
Celery for Task Queues in Python
18 / 51 · Python Libraries
Next
Beautiful Soup Web Scraping