Pytest Fixtures: Mutable Session Fixtures Cause Flaky Tests
Tests pass individually but fail randomly due to mutable session fixtures.
20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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
pytest fixtures are dependency injection containers for test setup and teardown. Instead of writing plain functions that you call manually in each test, you declare a fixture with @pytest.fixture, and pytest automatically injects its return value into any test function that lists the fixture name as a parameter.
This eliminates the boilerplate of calling setup functions, storing results in module-level variables, and manually cleaning up — all of which lead to flaky, order-dependent tests. A fixture can yield control back to the test and then run teardown code after the test completes, replacing the error-prone try/finally pattern you'd otherwise write yourself.
Fixtures have configurable scope: function (default, runs per test), class, module, package, or session. Session-scoped fixtures run once for the entire test run, which is great for expensive resources like database connections — but if that fixture returns a mutable object (e.g., a list, dict, or class instance), any test that mutates it corrupts state for all subsequent tests in that session.
This is the root cause of many flaky tests that pass in isolation but fail in a full suite. The fix is either to use scope="function" for mutable fixtures, or to return a deep copy inside the fixture function.
pytest is the de facto standard for Python testing — used by projects like NumPy, Django, and SQLAlchemy — because it scales from trivial unit tests to complex integration suites. Its fixture system replaces unittest's setUp/tearDown with composable, scoped, and parametrizable building blocks.
You install it with pip install pytest, and it works out of the box with zero configuration for most projects. Avoid it only if you're locked into an existing unittest-based framework with no migration path, or if you need strict JUnit XML compatibility for a legacy CI pipeline.
Imagine you run a coffee shop and every morning you have to set up the espresso machine, grind fresh beans, and warm the cups before the first customer walks in. You don't redo that prep for every single drink — you do it once and then serve from it all day. Pytest fixtures are exactly that: the 'morning prep' your tests share so they don't each have to build the world from scratch. One fixture sets up a database connection, a fake user, or a configured API client — and every test that needs it just asks for it by name.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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.
import pytest # --- Without fixtures (the painful way) --- # Notice how setup is duplicated in every test. This is the problem we're solving. def test_user_has_default_role_no_fixture(): # We have to manually create everything every single time user = {"id": 1, "username": "alice", "role": "viewer"} assert user["role"] == "viewer" def test_user_can_be_promoted_no_fixture(): # Exact same setup duplicated — a maintenance nightmare user = {"id": 1, "username": "alice", "role": "viewer"} user["role"] = "admin" assert user["role"] == "admin" # --- With fixtures (the right way) --- @pytest.fixture def sample_user(): """ This fixture creates a fresh user dict before each test that requests it. Pytest injects this automatically — the test never calls sample_user() directly. """ user = {"id": 1, "username": "alice", "role": "viewer"} return user # pytest hands this value to any test that lists 'sample_user' as a param def test_user_has_default_role(sample_user): # <-- pytest sees 'sample_user', runs the fixture assert sample_user["role"] == "viewer" def test_user_can_be_promoted(sample_user): # <-- fresh copy of the user dict for this test sample_user["role"] = "admin" assert sample_user["role"] == "admin" # Run with: pytest test_user_service.py -v
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.
import pytest import sqlite3 import os @pytest.fixture def temporary_database(): """ Creates a real SQLite database file before the test, yields the connection, then closes the connection and deletes the file — no matter what happens in the test. """ db_path = "test_temp.db" # --- SETUP: everything before yield --- connection = sqlite3.connect(db_path) cursor = connection.cursor() cursor.execute( "CREATE TABLE orders (id INTEGER PRIMARY KEY, product TEXT, quantity INTEGER)" ) connection.commit() print(f"\n[FIXTURE] Database created at {db_path}") yield connection # <-- the test receives this connection object # --- TEARDOWN: everything after yield --- # This block runs even if the test raises an exception or fails an assertion connection.close() os.remove(db_path) print(f"[FIXTURE] Database deleted — {db_path} cleaned up") def test_insert_order(temporary_database): # 'temporary_database' IS the yielded connection object cursor = temporary_database.cursor() cursor.execute("INSERT INTO orders (product, quantity) VALUES ('keyboard', 2)") temporary_database.commit() cursor.execute("SELECT product, quantity FROM orders WHERE product = 'keyboard'") result = cursor.fetchone() assert result == ("keyboard", 2) def test_empty_database_has_no_orders(temporary_database): # Each test gets a FRESH database — the previous test's insert doesn't bleed in cursor = temporary_database.cursor() cursor.execute("SELECT COUNT(*) FROM orders") count = cursor.fetchone()[0] assert count == 0 # proves teardown + fresh setup worked # Run with: pytest test_database_connection.py -v -s
return in a fixture, teardown code is impossible — there's nowhere to put it. Always use yield when your fixture opens a resource (file, socket, database connection, temporary directory) that needs closing. The rule is simple: if you opened it, yield it.return in a fixture that opened a network socket. The socket was never closed — after 200 tests the OS ran out of file descriptors and the entire suite crashed.return to yield and close the socket after.yield for any fixture that acquires resources.yield, guaranteed cleanup lives after.yield runs even if the test crashes — this is your safety net.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.
import pytest # ---- SESSION scope: created ONCE for the entire test run ---- @pytest.fixture(scope="session") def application_config(): """ Simulates loading a config file — expensive, so we do it once per session. Immutable data is safe to share across all tests. """ print("\n[SESSION] Loading application config (happens once)") config = { "api_base_url": "https://api.example.com", "max_retries": 3, "timeout_seconds": 30, } yield config print("[SESSION] Config teardown (happens once at end)") # ---- MODULE scope: created once per test file ---- @pytest.fixture(scope="module") def database_schema(application_config): """ Uses the session-scoped config to set up a schema once per module. A fixture CAN depend on a wider-scoped fixture, but NOT a narrower one. """ print(f"\n[MODULE] Setting up schema using {application_config['api_base_url']}") schema = {"tables": ["users", "products", "orders"], "version": "1.0"} yield schema print("[MODULE] Schema teardown") # ---- FUNCTION scope (default): fresh for every test ---- @pytest.fixture # scope="function" is the default — no need to specify def active_user_session(database_schema): """ Creates a fresh user session for each test. Depends on the module-scoped schema. A narrower fixture CAN safely depend on a wider one. """ print("\n[FUNCTION] Creating user session") session = { "session_id": "abc-123", "username": "alice", "schema_version": database_schema["version"] } yield session print("[FUNCTION] Destroying user session") # --- TESTS --- def test_session_has_correct_schema_version(active_user_session): assert active_user_session["schema_version"] == "1.0" def test_session_belongs_to_alice(active_user_session): # This test gets a FRESH user session — but reuses the same database_schema assert active_user_session["username"] == "alice" def test_config_has_retry_limit(application_config): # The session-scoped config is the exact same object as in the other tests assert application_config["max_retries"] == 3 # Run with: pytest test_scoped_fixtures.py -v -s # Watch the print statements to see EXACTLY when each scope initialises and tears down
--durations=0 to find scope candidates. Target the top 3 slowest fixtures.function scope for safety.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.
# conftest.py — place this in your project root or test directory # pytest auto-discovers this file. No imports needed in your test files. import pytest @pytest.fixture(scope="session") def api_client(): """ Simulates a configured HTTP client shared across all test files. In a real project this might be a requests.Session or httpx.Client. """ class FakeApiClient: def __init__(self, base_url): self.base_url = base_url self.headers = {"Authorization": "Bearer test-token-xyz"} def get(self, endpoint): # Simulating a response for illustration purposes return {"status": 200, "url": f"{self.base_url}{endpoint}"} client = FakeApiClient(base_url="https://api.example.com") print("\n[conftest] API client initialised") yield client print("[conftest] API client closed") @pytest.fixture def admin_user(): """A reusable admin user available to any test file in this directory tree.""" return {"id": 99, "username": "admin", "role": "admin", "email": "admin@example.com"} @pytest.fixture def viewer_user(): """A reusable read-only user for permission boundary tests.""" return {"id": 7, "username": "carol", "role": "viewer", "email": "carol@example.com"} # --------------------------------------------------------------- # test_permissions.py — in the same directory as conftest.py # Note: NO import of conftest needed. Pytest resolves fixtures by name. # --------------------------------------------------------------- # import pytest # (only needed if you use pytest.mark etc.) def test_admin_can_access_dashboard(api_client, admin_user): # Both fixtures come from conftest.py — zero imports response = api_client.get("/dashboard") assert response["status"] == 200 assert admin_user["role"] == "admin" def test_viewer_cannot_modify_settings(viewer_user): assert viewer_user["role"] == "viewer" # In a real test you'd call your auth system and assert 403 # Run with: pytest test_permissions.py -v -s
dummy_database fixture. 12 test files became zero imports and one source of truth.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.
import pytest # ---- Parametrized fixture: runs once per parameter value ---- @pytest.fixture(params=["viewer", "editor", "admin"]) def user_role(request): """ This fixture runs three times — once for each role. Any test that uses this fixture runs three times (once per parameter). The current parameter is accessed via request.param. """ role = request.param print(f"\n[FIXTURE] Setting up user with role: {role}") # Setup could involve creating a DB record with this role yield {"username": "test_user", "role": role} print(f"[FIXTURE] Teardown for role: {role}") # ---- Composition: multiple small fixtures combined ---- @pytest.fixture def fresh_user(): return {"id": 1, "username": "alice", "role": "viewer"} @pytest.fixture def logged_in_session(fresh_user): """ Composes a fresh_user fixture to simulate an authenticated session. Notice how this fixture depends on 'fresh_user' — that's how composition works. """ session = { "user": fresh_user, "token": "abc123", "authenticated": True } yield session # No cleanup needed because fresh_user is disposable # --- TESTS --- def test_role_based_permissions(user_role): """ This test runs three times: once with 'viewer', once with 'editor', once with 'admin'. Each run gets a different 'user_role' fixture value. """ role = user_role["role"] if role == "viewer": assert user_role["username"] == "test_user" elif role == "editor": assert user_role["role"] == "editor" else: assert user_role["role"] == "admin" def test_logged_in_session_has_user(logged_in_session): """ Uses the composed fixture. The test doesn't need to know about fresh_user — it just asks for logged_in_session and gets everything. """ assert logged_in_session["authenticated"] is True assert logged_in_session["user"]["username"] == "alice" # Run with: pytest test_parametrized_fixtures.py -v -s # You'll see 3 runs for test_role_based_permissions + 1 run for test_logged_in_session
- Each fixture should set up exactly one piece of state.
- A fixture that does three things is a crisis waiting to happen.
- Compose fixtures by depending on other fixtures — pytest resolves the graph.
- Parametrize only the variation point, not the entire setup.
- Your test function should read like a list of requirements: test_x(a, b, c) needs a, b, c.
How to Install pytest and What Makes It Worth the Effort
Before you write a single fixture, you need pytest on your machine. Install it with pip install pytest in a virtual environment. That's it. No unittest boilerplate, no TestCase classes, no setUp methods that scatter setup logic across your files.
Pytest cleans up three things that junior devs don't realize are wasting time. First, less boilerplate: a fixture is a decorator, not a class hierarchy. Second, nicer output: when a test fails, pytest shows you exactly which assertion broke and what the actual value was. Third, less to learn: you don't need to memorize unittest's inheritance rules. A fixture is a function that returns something. End of story.
The real win is managing state and dependencies. Without fixtures, you copy-paste setup logic into every test. One change breaks ten tests. Fixtures centralize that. You change one function, every test that depends on it gets the new behavior. That's the entire point of this framework.
// io.thecodeforge — python tutorial # run in terminal: # pip install pytest # then write a trivial test: def test_install_works(): assert 1 + 1 == 2
python -m venv .venv && source .venv/bin/activate is muscle memory for any pro.Fixtures in Pytest — The Core Pattern You Must Memorize
A fixture is just a function decorated with @pytest.fixture. It runs before your test, and its return value gets injected into the test function's arguments. No magic, no inheritance.
The pattern is simple: declare the fixture, return the resource, pass the fixture name as a parameter to the test. Pytest matches names. That's the whole trick.
Here's the why: without fixtures, you'd write the same database connection string, API client, or mock object in every test. One typo, one change, and you're updating ten files. Fixtures give you a single source of truth. You change the fixture, every test that uses it gets the update.
Two rules: (1) keep fixtures small — one resource per fixture. (2) name them for what they return, not what they do. db_connection, not setup_database. That makes the test read like a story: def test_user_lookup(db_connection):. Anyone can see what's happening without reading setup code.
// io.thecodeforge — python tutorial import pytest @pytest.fixture def database_client(): # In production, this would connect to your test DB return {"host": "localhost", "port": 5432, "connected": True} def test_insert_user(database_client): assert database_client["connected"] == True # Simulate an insert database_client["user_count"] = 1 assert database_client["user_count"] == 1 def test_query_user(database_client): # Each test gets a fresh fixture, no state leak assert database_client.get("user_count") is None
scope="function" (default) to ensure each test gets a clean copy. Sharing mutable state between tests is the #1 cause of flaky tests. Don't do it.Pre-requisite: Why Your Fixture Needs a Different Architecture for Network Calls
You've memorized the fixture decorator. Good. Now throw it away for anything that touches a socket. A fixture that calls a real API, hits a database, or spawns a subprocess will make your test suite slower than a CI pipeline on a Friday afternoon. Worse, it introduces flakiness when the network hiccups.
The fix: break your fixture into two layers. First, an interface (a protocol, an abstract base, or just a callable contract). Second, a concrete implementation that your fixture instantiates. In production, you inject the real implementation. In testing, you inject a fake or a mock that returns canned data in microseconds. This isn't theory — this is the pattern that lets you run 10,000 tests in 12 seconds.
The WHY is simple: stateful fixtures rot. A real connection is state. A cached response is not. Design your fixtures to return lightweight, stateless objects that mimic the shape of your dependencies, not the dependencies themselves.
// io.thecodeforge — python tutorial class PaymentGateway: def charge(self, amount: float) -> dict: # real HTTP call return requests.post("https://pay.example/charge", json={"amt": amount}).json() class FakePaymentGateway: def charge(self, amount: float) -> dict: return {"status": "ok", "id": "fake_123"} import pytest @pytest.fixture def payment_gateway() -> PaymentGateway: # switch this line to FakePaymentGateway in CI return FakePaymentGateway() def test_charge_success(payment_gateway): result = payment_gateway.charge(99.99) assert result["status"] == "ok" assert result["id"] == "fake_123"
responses or pytest-httpserver to mock at the transport layer — your fixture should return a tested mock, not a flaky wire.Compose Fixtures Like Lego — Then Destroy Them with `return` and `yield` in the Same Block
You already know yield gives you teardown. But here's the trick most devs miss: you can compose a fixture that creates a resource, passes it through a chain, and then destroys it — all without nesting try/finally blocks like a JavaScript callback pyramid.
When you have a fixture that needs to spin up a database container, seed it, hand the connection to a service fixture, then tear both down in reverse order, you write exactly one yield per fixture. Pytest handles the stack. The first fixture to yield is the last to clean up. This is deterministic, readable, and production-grade.
The WHY: manual teardown is the #1 source of leaked resources in test suites. A yield fixture that returns a connection and then closes it after the test cannot leak — it runs even if the test throws an exception. Compose these building blocks, and you get a clean database for every test without a single try.
Don't reuse connections across tests. Don't rely on conftest to hide your mess. Each fixture owns its lifecycle.
// io.thecodeforge — python tutorial import pytest class Database: def __init__(self): self.connected = False def connect(self): self.connected = True return self def close(self): self.connected = False class UserService: def __init__(self, db: Database): self.db = db def find(self, uid: int) -> dict: if not self.db.connected: raise RuntimeError("DB not connected") return {"id": uid, "name": "Alice"} @pytest.fixture def db(): d = Database().connect() yield d d.close() @pytest.fixture def service(db): return UserService(db) def test_user_fetch(service): user = service.find(1) assert user["name"] == "Alice" assert service.db.connected # still alive during test
pytest.fixture(autouse=True) only for global logging or env setup — never for test data. If every test shares a seeded DB via autouse, you've just rewritten a global variable. Compose explicitly.Marks: Categorizing Tests for Targeted Execution
When your test suite grows, running everything on every change becomes wasteful. Pytest marks let you tag tests with custom categories like slow, network, or smoke, then run only the subset relevant to your current task. This replaces conditional skips or manual test file organization with a declarative metadata system. Define marks by placing @pytest.mark.yourname above a test function. Register custom marks in pyproject.toml to suppress warnings and enable strict enforcement. For example, tag database-dependent tests with @pytest.mark.db, then run them in isolation with pytest -m db. Marks compose: you can filter by combination using logical expressions like -m 'db and not slow'. Use skipif marks to conditionally skip tests based on platform or environment without polluting test logic.
// io.thecodeforge — python tutorial import pytest @pytest.mark.slow def test_heavy_computation(): assert sum(range(10**6)) == 499999500000 @pytest.mark.network def test_api_connect(): assert True # placeholder def test_fast(): assert 2 + 2 == 4 # Run with: pytest -m "not slow"
[tool.pytest.ini_options] markers = ["slow", "network"] to keep your CI pipeline clean.Durations Reports: Fighting Slow Tests with Metrics
Slow tests accumulate silently, turning a 2-second suite into a 2-minute bottleneck. Pytest's --durations flag exposes exactly which tests waste time. Pass --durations=10 to print the 10 slowest tests and their execution times, ordered from slowest to fastest. Add --durations-min=1.0 to ignore everything under one second, focusing your attention on real offenders. This data lets you decide: optimize the test, cache the fixture, or bump its scope to module or session. For CI pipelines, pipe durations into a JSON report using --durations=0 (shows all) and parse it with a script to enforce time budgets per test. Combine with -q for concise output; integrate with pytest-json for structured logging.
// io.thecodeforge — python tutorial import pytest import time def test_fast(): pass @pytest.mark.slow def test_slow(): time.sleep(2.5) def test_medium(): time.sleep(0.3) # Run: pytest --durations=3 --durations-min=0.2
Useful pytest Plugins: Extending Without Reinventing
Pytest's plugin ecosystem solves specific problems that tempt you to write custom hacks. pytest-randomly shuffles test order to expose hidden interdependencies — run it in CI to catch tests that only pass when executed in a specific sequence. pytest-cov integrates coverage reporting directly into pytest output, showing line-by-line misses without a separate coverage.py run. pytest-xdist parallelizes test execution across CPU cores with pytest -n auto, cutting suite runtime proportionally to core count. For advanced needs: pytest-sugar prettifies output; pytest-timeout kills runaway tests; pytest-order enforces exact sequence when shuffling is unsafe. Install via pip, enable by default via conftest.py, and never write a test decorator that should have been a plugin.
// io.thecodeforge — python tutorial # Install: pip install pytest-randomly pytest-cov pytest-xdist # conftest.py to enable plugins by default pytest_plugins = [ 'pytest_randomly', 'pytest_cov', 'pytest_xdist', ] # Run with: pytest -n auto --cov=src --randomly-dont-reorganize
Pre-requisite: Why Your Fixture Needs a Different Architecture for Network Calls
Before writing fixtures that make real network requests, understand the architectural shift required. Network calls introduce latency, flakiness, and rate limits that sabotage test reliability. Your fixture should never call an external service directly during unit tests — instead, design a wrapper layer that can be swapped with a stub. This means injecting a service client via dependency injection rather than hardcoding imports. For integration tests, use a test double like responses or mocks.patch to intercept requests. The pre-requisite is a testable architecture: every network- dependent function must accept its transport as an argument. Without this, your fixture becomes a liability. Enforce this separation early, and you can parametrize fixtures to run the same test against real, mocked, or recorded responses.
// io.thecodeforge — python tutorial import responses import pytest from myapp import fetch_user @pytest.fixture def mock_api(): with responses.RequestsMock() as rsps: rsps.add(responses.GET, "https://api.example.com/user/1", json={"id": 1, "name": "Alice"}, status=200) yield rsps def test_fetch_user(mock_api): user = fetch_user(1) assert user["name"] == "Alice"
Example 2: Compose Fixtures Like Lego — Then Destroy Them with `return` and `yield` in the Same Block
Pytest fixtures can be composed like building blocks. A fixture that returns a value sets up state; a fixture with yield adds teardown. Combine them: have a user fixture yield an authenticated client, then compose it with a database fixture. The magic happens when you yield a resource and later return a derived value from the same fixture — but that's a common pitfall. Instead, keep them separate: a session-scoped DB connection yields, then a function-scoped client fixture returns a fresh wrapper. Use pytest.fixture autouse to apply teardown globally. For example, a fixture that yields a temporary directory, then another that returns a file path inside it. This keeps setup logically grouped and teardown guaranteed, even on exceptions. The key is yielding for external state (files, networks) and returning for computed values (configs, objects).
// io.thecodeforge — python tutorial import tempfile import pytest @pytest.fixture def tmp_dir(): with tempfile.TemporaryDirectory() as d: yield d @pytest.fixture def cfg_path(tmp_dir): return f"{tmp_dir}/config.ini" def test_config(cfg_path): with open(cfg_path, "w") as f: f.write("[test]") assert True
yield for fixtures that acquire and release external resources; use return for fixtures that compute or compose data without side effects.Conclusion: Useful pytest Plugins — Extending Without Reinventing
Pytest's plugin ecosystem solves common testing gaps without custom code. pytest-randomly reshuffles test order to catch hidden dependencies; add it with one line and it randomizes seed on each run, making flaky tests visible. pytest-cov integrates coverage reporting via --cov=myapp, showing untested lines instantly — pair it with --cov-report=html for interactive reports. pytest-django provides database setup for Django apps, letting fixtures create test records without manually flushing tables. pytest-bdd brings Behavior-Driven Development with Gherkin syntax, so stakeholders read plain-English scenarios that map to fixture-driven Python steps. Your takeaway: plugins replace weeks of custom framework code. To stabilize your suite: use pytest-randomly weekly, run pytest-cov in CI with fail-under 80%, and adopt pytest-django instead of custom setUp classes. The ecosystem is the final frontier of maintainable testing.
// io.thecodeforge — python tutorial # Install: pip install pytest-cov pytest-randomly pytest-django pytest-bdd # pytest.ini example: # [pytest] # addopts = --randomly-seed=42 --cov=src --cov-fail-under=80 # testpaths = tests # Run: # pytest --bdd-strict-gherkin features/
pytest-randomly on CI, and pin plugin versions to avoid breaking changes in your test runner.pytest-cov for coverage or pytest-bdd for collaboration — rather than building brittle custom infrastructure.Order-Dependent Test Suite After a Session-Scoped Fixture Change
pytest --random-order reveals the pattern: test B fails only when test A ran before it.list of API endpoints. One test called a helper that appended a new endpoint to the list. Because the list was a single object shared across all tests, subsequent tests saw the modified list. The fixture was meant to be immutable shared configuration, but using a list allowed accidental mutation.tuple instead of a list — tuples are immutable and cannot be accidentally modified. Any test that needed to add an endpoint now had to create a new tuple explicitly, making the mutation visible in code review.- Session-scoped fixtures must return immutable data. Use tuples, frozensets, namedtuples, or frozen dataclasses.
- If you must return mutable data, wrap it in a function-scoped fixture that copies from the session-scoped source.
- Always run your test suite with
pytest --random-orderbefore merging to catch hidden order dependencies.
pytest --fixtures to list all available fixtures.pytest --setup-show to see exactly when each fixture is created and torn down.yield not return. Code after yield is guaranteed teardown. If you used return, replace it with yield and move cleanup after it. Also verify that the fixture isn't raising an exception before reaching the yield.pytest --co -q # shows collected fixtures and their scopesgrep -r '@pytest.fixture(scope=' test_directory/ # list all fixture scopes in projectpytest -s --tb=short test_file.py::test_name # run with stdout visibleimport pytest; print(type(yielded_value)) # add inside fixturepytest --fixtures-per-test test_file.py # shows which fixtures each test seesls -la $(find . -name conftest.py) # list all conftest files in project| Fixtures | Created / Destroyed | Best For | Isolation Risk |
|---|---|---|---|
| function (default) | Before / after each test | Mutable objects — dicts, lists, model instances | None — each test is fully isolated |
| class | Once per test class | Related tests in a class that share lightweight state | Low — scoped to one class |
| module | Once per .py test file | DB schema setup, file parsing, module-level config | Medium — all tests in file share state |
| package | Once per package directory | Integration test suites with expensive shared infra | Medium-High — broad sharing |
| session | Once per entire test run | Server startup, auth tokens, ML model loading | High — any mutation affects all tests |
| File | Command / Code | Purpose |
|---|---|---|
| test_user_service.py | def test_user_has_default_role_no_fixture(): | What a Fixture Actually Is |
| test_database_connection.py | @pytest.fixture | Teardown Without Try/Finally |
| test_scoped_fixtures.py | @pytest.fixture(scope="session") | Fixture Scope |
| conftest.py | @pytest.fixture(scope="session") | conftest.py |
| test_parametrized_fixtures.py | @pytest.fixture(params=["viewer", "editor", "admin"]) | Fixture Parametrization and Composition |
| InstallCheck.py | def test_install_works(): | How to Install pytest and What Makes It Worth the Effort |
| FixturePattern.py | @pytest.fixture | Fixtures in Pytest |
| Fixture_Architecture.py | class PaymentGateway: | Pre-requisite |
| Fixture_Composition.py | class Database: | Compose Fixtures Like Lego |
| marks_demo.py | @pytest.mark.slow | Marks |
| durations_report.py | def test_fast(): | Durations Reports |
| plugin_setup.py | pytest_plugins = [ | Useful pytest Plugins |
| test_div_by_3_6.py | from myapp import fetch_user | Pre-requisite |
| test_div_by_3_6.py | @pytest.fixture | Example 2: Compose Fixtures Like Lego |
Key takeaways
Common mistakes to avoid
3 patternsSharing mutable state with a wide-scoped fixture
pytest --random-order reveals the order dependency.copy.deepcopy().Putting teardown code after a return statement instead of using yield
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
test_something.py but test_other.py can't see it.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
What 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?
How 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?
If 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?
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
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.
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.
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.
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.
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.
20+ years shipping production Python across data and backend systems. Written from production experience, not tutorials.
That's Python Libraries. Mark it forged?
10 min read · try the examples if you haven't