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:

ScopeWhen it's createdWhen it's torn downTypical use
functionEvery testAfter each testAn empty DB, isolated data, cheap resources
classOnce per test classAfter the classGrouped tests that share setup
moduleOnce per fileAfter the moduleExpensive resources used by all the tests in the file
sessionOnce in the whole sessionAt the endGlobal 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

ScenarioRecommended scopeReason
A database with mutable datafunctionTotal isolation, each test starts clean
A stateless HTTP client (read-only)module or sessionAvoid creating the connection 50 times
A config dict/list that gets modifiedfunctionAvoid contamination between tests
Immutable config (read-only)sessionOne instance for the whole suite
Tests in a class that share setupclassA balance between reuse and isolation
A connection to a real PostgreSQLmodule at minimumCreating 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

ApproachWhen to use itExample
A simple fixtureA single object with fixed dataA user that always returns the same admin
A factory fixtureSeveral instances with different attributesmake_user(role="admin"), make_user(role="guest")
A composed fixtureData derived from another resourceauth_token(user)
Factory + composedSeveral instances that depend on a resourcemake_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):

  1. The test file itself (if it defines fixtures)
  2. The conftest.py in the same directory
  3. The conftest.py in the parent directory
  4. 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:

  1. Paste pytest's error: "fixture 'db' not found" or "get_db is not defined"
  2. Specify the contract: "The create_user function in FakeDB only receives an email, not a role. The role is assigned afterwards."
  3. 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: function for the db and mutable data, session for 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/session for 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

  1. pytest Fixtures — Official documentation - A fixtures reference
  2. pytest Fixture Scope - function, class, module, session
  3. Parametrizing fixtures - Fixtures with params
  4. conftest.py: sharing fixtures - Fixture organization
  5. Factory Boy - Factory patterns for test data
  6. Testing Best Practices (Real Python) - Testing best practices

Module 6, Capsule 03 — Testing with Claude Code Guide