Pytest Fixtures Explained — Setup, Scopes, and Real-World Patterns
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
test_user_service.py::test_user_can_be_promoted PASSED
2 passed in 0.03s
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
[FIXTURE] Database deleted — test_temp.db cleaned up
[FIXTURE] Database created at test_temp.db
[FIXTURE] Database deleted — test_temp.db cleaned up
test_database_connection.py::test_insert_order PASSED
test_database_connection.py::test_empty_database_has_no_orders PASSED
2 passed in 0.08s
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
[MODULE] Setting up schema using https://api.example.com
[FUNCTION] Creating user session
test_scoped_fixtures.py::test_session_has_correct_schema_version PASSED
[FUNCTION] Destroying user session
[FUNCTION] Creating user session
test_scoped_fixtures.py::test_session_belongs_to_alice PASSED
[FUNCTION] Destroying user session
test_scoped_fixtures.py::test_config_has_retry_limit PASSED
[MODULE] Schema teardown
[SESSION] Config teardown (happens once at end)
3 passed in 0.05s
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
test_permissions.py::test_admin_can_access_dashboard PASSED
test_permissions.py::test_viewer_cannot_modify_settings PASSED
[conftest] API client closed
2 passed in 0.04s
| Fixture Scope | 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 |
🎯 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.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Sharing mutable state with a wide-scoped fixture — If a session-scoped fixture returns a dict and one test modifies it, every subsequent test sees the modified dict, causing flaky tests that pass or fail depending on execution order. Fix: make wide-scoped fixtures return immutable data (tuples, frozensets, namedtuples), or use a function-scoped fixture that copies from a session-scoped source using dict.copy().
- ✕Mistake 2: Putting teardown code after a return statement instead of using yield — The teardown code is unreachable after return, so database connections, temp files, and open sockets are never cleaned up. The symptom is resource leaks and 'database is locked' errors on the second test run. Fix: replace return with yield — everything after yield is your guaranteed teardown block.
- ✕Mistake 3: Defining fixtures inside test files and wondering why other test files can't see them — Pytest only auto-discovers fixtures from conftest.py files, not from arbitrary test_*.py files. The symptom is a 'fixture not found' error despite the fixture clearly existing somewhere. Fix: move shared fixtures to conftest.py at the appropriate directory level — no imports needed anywhere.
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?
- 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?
- 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?
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.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.