Module 6: Mocking, Fixtures and Validation Loops
Advanced pytest Fixtures
Advanced pytest Fixtures
Capsule overview
You already know basic fixtures: a function with @pytest.fixture that prepares data or resources for your tests. But when the project grows — dozens of tests, several types (unit, integration, E2E) — the fixtures turn into chaos if you don't organize them well. A test that uses a shared db can contaminate another one. A fixture that creates a user in every test slows everything down. A 500-line conftest.py is impossible to maintain.
This capsule teaches you advanced fixtures: scope to control how many times a resource is created, composition to chain fixtures, factory fixtures for variable data, professional organization with a conftest.py per directory, autouse when you need global setup, and parametrized fixtures for testing against multiple backends. At the end you'll see how to generate realistic fixtures with Claude Code. With this you'll have the foundation for a project with maintainable, scalable tests.
Fixture Scope: Controlling the Lifecycle
The problem: expensive resources per test
By default, pytest creates a new fixture for each test that uses it. If you have 50 tests that ask for db, pytest runs db's setup 50 times. For an in-memory database that's fine. For a PostgreSQL connection, an HTTP client, or a service that takes 2 seconds to start, it's a waste.
The scope controls how many times the fixture is instantiated:
| Scope | When it's created | When it's torn down | Typical use |
|---|---|---|---|
function | Every test | After each test | An empty DB, isolated data, cheap resources |
class | Once per test class | After the class | Grouped tests that share setup |
module | Once per file | After the module | Expensive resources used by all the tests in the file |
session | Once in the whole session | At the end | Global config, a shared HTTP client |
function (the default): a new fixture per test
# test_example.py
import pytest
@pytest.fixture # scope="function" is the default
def db():
"""Each test gets a new db."""
return {"items": [], "next_id": 1}
def test_a(db):
db["items"].append("a")
assert len(db["items"]) == 1
def test_b(db):
# db is empty — test_a didn't contaminate it
assert len(db["items"]) == 0
Each test has total isolation. It's the right choice for databases and mutable data.
module: sharing between the tests in the same file
# test_example.py
import pytest
def create_expensive_resource():
"""Simulates a resource that takes time to create."""
return {"initialized": True}
def cleanup(resource):
"""Releases resources."""
pass
@pytest.fixture(scope="module")
def expensive_resource():
resource = create_expensive_resource()
yield resource
cleanup(resource)
def test_uses_resource_1(expensive_resource):
assert expensive_resource["initialized"] is True
def test_uses_resource_2(expensive_resource):
# The same instance as test_uses_resource_1
assert expensive_resource["initialized"] is True
With scope="module", the fixture is created just once for the whole file. The teardown runs at the end of the module.
session: sharing across the whole suite
# conftest.py
import pytest
@pytest.fixture(scope="session")
def app_config():
"""Config that doesn't change during the entire session."""
return {"debug": True, "env": "test"}
def test_in_file_a(app_config):
assert app_config["env"] == "test"
# test_other.py
def test_in_file_b(app_config):
# The same instance as in any other file
assert app_config["debug"] is True
Use session only for truly immutable resources. If the fixture holds mutable state, the tests can contaminate each other.
class: sharing within a class
import pytest
@pytest.fixture(scope="class")
def shared_client():
"""An HTTP client shared by the class."""
return {"base_url": "https://api.test.com"}
class TestUserEndpoints:
def test_list_users(self, shared_client):
assert shared_client["base_url"] == "https://api.test.com"
def test_get_user(self, shared_client):
# The same instance as test_list_users
assert "base_url" in shared_client
Useful when you group related tests and want to share setup without going all the way to module.
When to choose each scope
| Scenario | Recommended scope | Reason |
|---|---|---|
| A database with mutable data | function | Total isolation, each test starts clean |
| A stateless HTTP client (read-only) | module or session | Avoid creating the connection 50 times |
| A config dict/list that gets modified | function | Avoid contamination between tests |
| Immutable config (read-only) | session | One instance for the whole suite |
| Tests in a class that share setup | class | A balance between reuse and isolation |
| A connection to a real PostgreSQL | module at minimum | Creating the connection is expensive; watch out for mutation |
A practical rule: start with function. Only switch to module or session when you measure and confirm that the setup is a bottleneck.
Fixture Composition: Chaining Dependencies
Fixtures can depend on other fixtures. pytest resolves the dependency graph automatically.
Basic composition
# conftest.py or test_*.py
import pytest
@pytest.fixture
def db():
"""An empty database."""
return create_db()
@pytest.fixture
def user(db):
"""A user created in the db."""
return db.create_user("test@test.com")
@pytest.fixture
def auth_token(user):
"""A token generated for the user."""
return generate_token(user.id)
def test_protected_endpoint(auth_token):
# pytest runs: db → user → auth_token
headers = {"Authorization": f"Bearer {auth_token}"}
response = client.get("/me", headers=headers)
assert response.status_code == 200
auth_token depends on user, which depends on db. pytest creates db, then user(db), then auth_token(user). You don't need to indicate the order.
A complete example with runnable code
# database.py
def create_db():
return {"users": [], "next_id": 1}
class FakeDB:
def __init__(self):
self.data = create_db()
def create_user(self, email: str):
uid = self.data["next_id"]
self.data["next_id"] += 1
user = {"id": uid, "email": email}
self.data["users"].append(user)
return user
def get_user(self, uid: int):
for u in self.data["users"]:
if u["id"] == uid:
return u
return None
# auth_utils.py
def generate_token(user_id: int) -> str:
return f"token_for_{user_id}"
# test_auth.py
import pytest
from database import FakeDB
from auth_utils import generate_token
@pytest.fixture
def db():
return FakeDB()
@pytest.fixture
def user(db):
return db.create_user("test@test.com")
@pytest.fixture
def auth_token(user):
return generate_token(user["id"])
def test_auth_token_format(auth_token):
assert auth_token.startswith("token_for_")
def test_user_exists_in_db(db, user):
u = db.get_user(user["id"])
assert u is not None
assert u["email"] == "test@test.com"
Every test that asks for auth_token gets a fresh user and token, because db has function scope by default.
Execution order and circular dependencies
pytest builds a DAG (directed acyclic graph) of fixtures. If A depends on B and B on C, the execution order is C → B → A. Circular dependencies aren't allowed:
# ❌ This fails
@pytest.fixture
def a(b):
return b
@pytest.fixture
def b(a):
return a
If you need to share logic between two fixtures that reference each other, extract that logic into a helper function or into a third fixture that both use.
Factory Fixtures: Variable Data per Test
Sometimes you need to create several instances with different attributes. Instead of a fixture that returns a single object, you return a function that creates objects on demand.
The basic pattern
@pytest.fixture
def make_user():
def _make_user(name="Test", email="test@test.com", role="user"):
return {"name": name, "email": email, "role": role}
return _make_user
def test_admin(make_user):
admin = make_user(name="Admin", role="admin")
assert admin["role"] == "admin"
def test_guest(make_user):
guest = make_user(name="Guest", role="guest")
assert guest["role"] == "guest"
def test_default_user(make_user):
user = make_user() # Uses the defaults
assert user["name"] == "Test"
assert user["role"] == "user"
make_user is a fixture that returns a function. Each test calls that function with the parameters it needs.
A factory with dependencies
@pytest.fixture
def db():
return FakeDB()
@pytest.fixture
def make_user(db):
def _make_user(name="Test", email="test@test.com", role="user"):
user = db.create_user(email)
user["name"] = name
user["role"] = role
return user
return _make_user
def test_create_admin_and_guest(make_user, db):
admin = make_user(name="Admin", role="admin")
guest = make_user(name="Guest", role="guest")
assert db.get_user(admin["id"])["role"] == "admin"
assert db.get_user(guest["id"])["role"] == "guest"
The factory can use other fixtures. Here make_user uses db to persist.
Factory vs a simple fixture
| Approach | When to use it | Example |
|---|---|---|
| A simple fixture | A single object with fixed data | A user that always returns the same admin |
| A factory fixture | Several instances with different attributes | make_user(role="admin"), make_user(role="guest") |
| A composed fixture | Data derived from another resource | auth_token(user) |
| Factory + composed | Several instances that depend on a resource | make_user(db) with db injected |
The factory is ideal when each test needs slightly different data: different roles, emails, quantities. You avoid creating 10 fixtures (admin_user, guest_user, premium_user, ...) and have a single parametrizable make_user.
conftest.py: Organization by Directory
Pytest looks for fixtures in conftest.py hierarchically. Each directory can have its own conftest.py. The fixtures are discovered automatically.
The recommended structure
tests/
├── conftest.py # Global: client, db, auth_token
├── unit/
│ ├── conftest.py # Unit-specific: mock data, simple fixtures
│ └── test_logic.py
├── integration/
│ ├── conftest.py # Integration-specific: TestClient, db fixtures
│ └── test_endpoints.py
└── e2e/
├── conftest.py # E2E-specific: a seeded db, auth config
└── test_flows.py
The global conftest.py
# tests/conftest.py
import pytest
from database import FakeDB
@pytest.fixture
def db():
"""A database for any test that needs it."""
return FakeDB()
@pytest.fixture(scope="session")
def app_config():
"""Config shared across the whole session."""
return {"env": "test", "debug": True}
conftest.py for unit
# tests/unit/conftest.py
import pytest
@pytest.fixture
def sample_user():
"""An example user with no persistence."""
return {"id": 1, "name": "Test", "email": "test@test.com"}
@pytest.fixture
def make_user():
def _make(name="Test", email="test@test.com"):
return {"name": name, "email": email}
return _make
The tests in tests/unit/ see the fixtures from tests/conftest.py and from tests/unit/conftest.py.
conftest.py for integration
# tests/integration/conftest.py
import pytest
from fastapi.testclient import TestClient
from main import app
@pytest.fixture
def client(db):
"""A TestClient with the db injected."""
def override_get_db():
yield db
app.dependency_overrides[get_db] = override_get_db
with TestClient(app) as c:
yield c
app.dependency_overrides.clear()
The integration tests use client and db without importing them explicitly.
conftest.py for E2E
# tests/e2e/conftest.py
import pytest
@pytest.fixture
def seeded_db(db):
"""A DB with test data for complete flows."""
db.create_user("admin@test.com")
db.create_user("user@test.com")
return db
Each level adds what it needs. The E2E tests can use seeded_db as well as the inherited db and app_config.
The discovery hierarchy
pytest loads fixtures in this order (from most specific to most general):
- The test file itself (if it defines fixtures)
- The
conftest.pyin the same directory - The
conftest.pyin the parent directory - And so on up to the root
If tests/unit/conftest.py defines a sample_data fixture and tests/conftest.py also defines it, the one from the closest directory wins (unit). Fixtures aren't overwritten by inheritance: the one closest to the test takes priority. That's why it's a good idea to have only what's truly shared in the global one, and what's specific in each subdirectory.
autouse Fixtures: Automatic Setup
A fixture with autouse=True runs in every test in the scope where it's defined, without you declaring it as a parameter.
When to use it
- Resetting global state before each test
- Configuring temporary environment variables
- Cleaning up shared resources
An example
@pytest.fixture(autouse=True)
def reset_state():
"""Cleans the global state before and after each test."""
reset_database()
yield
reset_database()
def test_something():
# reset_state ran even though you didn't ask for it
assert get_global_state() == {}
When not to use it
- ⚠️ If the fixture is expensive: it will run in tests that don't need it
- ⚠️ If the effect is implicit: it can confuse whoever reads the test
- ✅ Better for lightweight reset or configuration fixtures
autouse with scope
You can combine autouse=True with scope to control the reach:
@pytest.fixture(autouse=True, scope="module")
def setup_module_logging():
"""Configures logging once per module."""
import logging
logging.basicConfig(level=logging.DEBUG)
yield
logging.getLogger().handlers = []
With scope="module" the setup runs once when entering the file, not in every test.
Parametrized Fixtures: Multiple Backends
A fixture can run multiple times with different parameters. Each test that uses it runs for each value.
The syntax
@pytest.fixture(params=["sqlite", "postgres", "mysql"])
def database(request):
return create_database(request.param)
def test_connection(database):
# It runs 3 times: once per backend
assert database.is_connected()
request.param contains the current value ("sqlite", "postgres", "mysql").
An example with simulated backends
# test_storage.py
import pytest
def create_database(backend: str):
"""Simulates creation according to the backend."""
return {"backend": backend, "connected": True}
@pytest.fixture(params=["sqlite", "postgres"])
def database(request):
db = create_database(request.param)
yield db
# Teardown if it were necessary
db["connected"] = False
def test_insert(database):
assert database["connected"] is True
assert database["backend"] in ("sqlite", "postgres")
When you run pytest -v you'll see:
test_insert[sqlite] PASSED
test_insert[postgres] PASSED
Combining with parametrize
@pytest.fixture(params=["sqlite", "postgres"])
def database(request):
return create_database(request.param)
@pytest.mark.parametrize("value", [1, 2, 3])
def test_something(database, value):
# 2 backends × 3 values = 6 runs
assert database.insert("key", value) is not None
Generating Fixtures with Claude Code
Claude Code can generate realistic fixtures if you give it clear context. The prompt must specify what data you need and what structure it has.
The base prompt
Generate fixtures for my tests of [module/service].
I need:
- [describe the data: users, items, permissions, etc.]
- [the structure: dict, class, ORM model]
- [the relationships: a user has N items, etc.]
- [default values and variants]
An example prompt
Generate fixtures for my tests of the `users` module.
I need:
1. A `db` fixture: an empty FakeDB database
2. A `make_user(db)` fixture: a factory that creates users with name, email, role (default: "user")
3. An `admin_user` fixture: a user with role="admin" already created in the db
4. An `auth_token(user)` fixture: a JWT token for the user (it can be a dummy string)
5. The users must have id, name, email, role
6. Use a dict structure for simplicity
An example Claude Code response
# conftest.py
import pytest
from database import FakeDB
@pytest.fixture
def db():
return FakeDB()
@pytest.fixture
def make_user(db):
def _make_user(name="Test User", email="test@test.com", role="user"):
user = db.create_user(email)
user["name"] = name
user["role"] = role
return user
return _make_user
@pytest.fixture
def admin_user(db):
return db.create_user("admin@test.com", role="admin")
@pytest.fixture
def auth_token(user):
return f"Bearer mock_token_{user['id']}"
Adjust the output if the names don't match your code. The cycle is: generate → run the tests → fix → repeat.
Iterating when the generation fails
If Claude Code generates fixtures that don't compile or don't match your API:
- Paste pytest's error: "fixture 'db' not found" or "get_db is not defined"
- Specify the contract: "The create_user function in FakeDB only receives an email, not a role. The role is assigned afterwards."
- Ask for minimal fixtures: "I only need db, make_user and auth_token. Nothing else."
The more specific you are in the prompt, the fewer iterations you'll need.
Exercises
Exercise 1: A fixture with module scope
Create a config fixture with module scope that returns {"env": "test"}. Write two tests that use it and verify that they receive the same instance (you can store the id() in a module variable to compare).
See solution
# test_scope.py
import pytest
_config_ids = []
@pytest.fixture(scope="module")
def config():
return {"env": "test"}
def test_config_1(config):
_config_ids.append(id(config))
assert config["env"] == "test"
def test_config_2(config):
_config_ids.append(id(config))
assert config["env"] == "test"
def test_same_instance():
assert len(_config_ids) >= 2
assert _config_ids[0] == _config_ids[1]
Note: Comparing id() in a third test is a bit fragile (execution order). An alternative: use a module variable inside the fixture to count how many times it was created:
_creation_count = 0
@pytest.fixture(scope="module")
def config():
global _creation_count
_creation_count += 1
return {"env": "test"}
def test_config_created_once(config):
assert _creation_count == 1
Exercise 2: Chained composition
Create the db, user(db) and auth_token(user) fixtures for a system where user has an id and email, and auth_token returns f"token_{user['id']}". Write a test that uses auth_token and verifies that the token contains the user's id.
See solution
# test_composition.py
import pytest
def create_db():
return {"users": [], "next_id": 1}
@pytest.fixture
def db():
return {"users": [], "next_id": 1}
@pytest.fixture
def user(db):
uid = db["next_id"]
db["next_id"] += 1
user_data = {"id": uid, "email": "test@test.com"}
db["users"].append(user_data)
return user_data
@pytest.fixture
def auth_token(user):
return f"token_{user['id']}"
def test_auth_token_contains_user_id(auth_token, user):
assert auth_token == f"token_{user['id']}"
assert user["id"] in auth_token
Exercise 3: A factory fixture
Create a make_item fixture that returns a function to create items with name and price (defaults: "Item", 10.0). Write a test that creates two items with different values and verifies both.
See solution
# test_factory.py
import pytest
@pytest.fixture
def make_item():
def _make_item(name="Item", price=10.0):
return {"name": name, "price": price}
return _make_item
def test_two_items_different_values(make_item):
item1 = make_item(name="Laptop", price=999.99)
item2 = make_item(name="Mouse", price=29.99)
assert item1["name"] == "Laptop" and item1["price"] == 999.99
assert item2["name"] == "Mouse" and item2["price"] == 29.99
def test_default_item(make_item):
item = make_item()
assert item["name"] == "Item"
assert item["price"] == 10.0
Exercise 4: conftest.py per directory
Create the structure tests/unit/conftest.py and tests/integration/conftest.py. In unit define sample_data (a dict with a key "value": 42). In integration define client, which returns a dict {"base_url": "http://test"}. Write a test in each folder that uses its local fixture.
See solution
# tests/conftest.py
import pytest
@pytest.fixture
def db():
return {"items": []}
# tests/unit/conftest.py
import pytest
@pytest.fixture
def sample_data():
return {"value": 42}
# tests/unit/test_logic.py
def test_sample_data(sample_data):
assert sample_data["value"] == 42
# tests/integration/conftest.py
import pytest
@pytest.fixture
def client():
return {"base_url": "http://test"}
# tests/integration/test_api.py
def test_client(client):
assert client["base_url"] == "http://test"
Exercise 5: An autouse fixture
Create an autouse=True fixture that resets a global _counter variable to 0 before each test. Write two tests that increment _counter and verify that each test sees _counter == 1 (because they started from 0).
See solution
# test_autouse.py
import pytest
_counter = 0
@pytest.fixture(autouse=True)
def reset_counter():
global _counter
_counter = 0
yield
_counter = 0
def test_increment_once():
global _counter
_counter += 1
assert _counter == 1
def test_increment_once_again():
global _counter
_counter += 1
assert _counter == 1 # The previous reset, so 0+1=1
Exercise 6: A parametrized fixture
Create a storage fixture with params=["memory", "file"] that returns {"backend": request.param}. Write a test that verifies that storage["backend"] is in the list of params.
See solution
# test_parametrized_fixture.py
import pytest
@pytest.fixture(params=["memory", "file"])
def storage(request):
return {"backend": request.param}
def test_storage_backend(storage):
assert storage["backend"] in ("memory", "file")
When you run pytest -v:
test_storage_backend[memory] PASSED
test_storage_backend[file] PASSED
Troubleshooting
Problem 1: A fixture with module scope contaminates tests
Symptom: The tests fail when run together but pass individually.
Cause: A fixture with scope="module" or scope="session" holds mutable state that one test modifies and another reads.
Solution: For mutable data (db, lists, dicts), use scope="function". Reserve module/session for immutable or read-only resources (config, a stateless HTTP client).
Problem 2: A fixture isn't found in a subdirectory
Symptom: fixture 'X' not found when running tests in tests/unit/.
Cause: The fixture is in another directory's conftest.py and isn't inherited, or the current directory's conftest.py doesn't define it.
Solution: Fixtures in tests/conftest.py are inherited by every subdirectory. If you define the fixture in tests/unit/conftest.py, it's only available in tests/unit/. Verify that the file is named exactly conftest.py and that pytest discovers the directory.
Problem 3: A circular dependency between fixtures
Symptom: A "recursion" or "maximum recursion depth" error when running the tests.
Cause: Fixture A depends on B, B depends on C, C depends on A.
Solution: Break the cycle. Extract the common logic into a third fixture or into a helper function that several fixtures call without creating a circular dependency.
Problem 4: An autouse fixture slows down all the tests
Symptom: The suite takes much longer than expected.
Cause: The autouse=True fixture does something expensive (creating a DB, calling an API) in every test, even in the ones that don't need it.
Solution: Remove autouse and pass the fixture explicitly only to the tests that need it. Or use scope="session" if the setup can be shared safely.
Problem 5: A parametrized fixture generates too many tests
Symptom: params=[a, b, c, d, e] combined with @pytest.mark.parametrize generates dozens of runs.
Cause: A cartesian product: 5 params × 5 parametrize = 25 tests.
Solution: Reduce the params to the backends or variants you really need. If you only want to verify basic compatibility, 2-3 values are usually enough.
Project Connection
This module's validation pipeline project requires a professional conftest.py with organized fixtures:
- ✅ Global fixtures in
tests/conftest.py:db,client(a TestClient with the db injected),auth_token - ✅ Fixtures per test type: unit with simple mock data, integration with a TestClient and dependency overrides, E2E with a pre-populated db
- ✅ Factory fixtures to create users, items, or API responses with different attributes
- ✅ The right scope:
functionfor the db and mutable data,sessionfor immutable config - ✅ Parametrized fixtures if you need to test against several backends (e.g. sqlite vs postgres)
Everything you practice here gets used directly in capsule 06's project.
Summary
- ✅ Scope:
function(the default) for isolation,module/sessionfor expensive, immutable resources - ✅ Composition: Fixtures can depend on others; pytest resolves the graph automatically
- ✅ Factory fixtures: They return a function to create variable data per test
- ✅ conftest.py: Organize fixtures by directory (global, unit, integration, e2e)
- ✅ autouse: Runs setup/teardown in every test; use it sparingly
- ✅ Parametrized fixtures:
params=[...]to run tests against multiple variants - ✅ Claude Code: Prompts with the data structure and dependencies generate realistic fixtures
Next capsule: Validation loops with Claude Code — the automatic test→fix→re-test cycle.
Additional Resources
- pytest Fixtures — Official documentation - A fixtures reference
- pytest Fixture Scope - function, class, module, session
- Parametrizing fixtures - Fixtures with params
- conftest.py: sharing fixtures - Fixture organization
- Factory Boy - Factory patterns for test data
- Testing Best Practices (Real Python) - Testing best practices
Module 6, Capsule 03 — Testing with Claude Code Guide