backend / testing / pytest / 03_fixtures_deep_dive.md

Fixtures deep dive

3 min read source

Fixtures deep dive

Fixtures are pytest’s dependency-injection system. A test function declares fixtures by name in its signature; pytest resolves and provides them.

Scopes

@pytest.fixture(scope="function")  # default
@pytest.fixture(scope="class")
@pytest.fixture(scope="module")
@pytest.fixture(scope="package")
@pytest.fixture(scope="session")
Scope Lifetime Use for
function one test mocks, fresh data
class one test class shared setup within class TestThing
module one .py file DB schema, HTTP mock server
package folder package-level resources
session whole pytest run DB connection, Docker container

Larger scopes share state across tests. If a test mutates the resource, smaller scope or explicit reset.

@pytest.fixture(scope="session")
def database():
    db = create_test_db()
    yield db
    db.drop()

@pytest.fixture
def clean_db(database):
    # function-scope wrapper that depends on session-scope db
    database.truncate_all()
    yield database

Yield-based fixtures (setup + teardown)

@pytest.fixture
def temp_file(tmp_path):
    path = tmp_path / "data.txt"
    path.write_text("hello")
    yield path             # test runs here
    # teardown after yield, runs even on test failure

Anything before yield is setup, after is teardown. Equivalent to old-style request.addfinalizer(...).

autouse — run without asking

@pytest.fixture(autouse=True)
def reset_singleton():
    Singleton.reset()
    yield

Every test in the fixture’s scope gets it whether it asks or not. Use sparingly — invisible side effects are hard to debug. Best for cross-cutting concerns: clean caches, reset env vars, freeze time.

Parametrizing fixtures

@pytest.fixture(params=["sqlite", "postgres", "mysql"])
def db(request):
    return connect(request.param)

def test_query(db):
    # runs three times: once per backend
    assert db.execute("SELECT 1").fetchone()[0] == 1

request.param exposes the current value. Use ids=... for readable test names:

@pytest.fixture(params=[("sqlite", ":memory:"), ("postgres", "localhost")],
                ids=["sqlite", "postgres"])
def db(request):
    backend, host = request.param
    ...

Composition

Fixtures depend on other fixtures by argument name. Pytest builds a DAG and resolves bottom-up.

@pytest.fixture
def session(database):       # depends on `database`
    s = database.session()
    yield s
    s.close()

@pytest.fixture
def admin_user(session):     # depends on `session`
    u = User(role="admin")
    session.add(u); session.commit()
    return u

def test_admin_can_delete(admin_user, session):
    # both fixtures available; admin_user already used session
    ...

Pytest deduplicates: session is created once even if multiple fixtures need it (within scope).

conftest.py — shared fixtures

Fixtures defined in conftest.py are available to all tests in the same directory and subdirectories. No import needed.

tests/
├── conftest.py        # fixtures available everywhere below
├── api/
│   ├── conftest.py    # fixtures only for api/
│   └── test_users.py
└── unit/
    └── test_utils.py

Closer conftest.py files override outer ones for the same fixture name.

indirect parametrization

Pass parametrize values through a fixture instead of as direct test args:

@pytest.fixture
def user(request):
    return User(name=request.param)

@pytest.mark.parametrize("user", ["alice", "bob"], indirect=True)
def test_login(user):
    assert user.login()

Without indirect=True, "alice" would be passed straight to the test as the user arg. With it, the fixture receives request.param = "alice" and returns a User.

Why bother: lets you transform parametrize values into rich objects (DB rows, configured clients) while keeping parametrize-style cartesian products and IDs.

request.param, request.node, request.fixturenames

The request fixture is implicit access to test metadata.

@pytest.fixture
def fixture_aware(request):
    print(f"used by: {request.node.name}")
    print(f"all fixtures requested: {request.fixturenames}")
    print(f"param: {getattr(request, 'param', None)}")

Useful for fixtures that adapt behavior based on the calling test (e.g., a logger that names the test).

Common patterns

Factory fixture — returns a callable so tests create as many as they need:

@pytest.fixture
def make_user(session):
    created = []
    def _make(**overrides):
        u = User(name="default", **overrides)
        session.add(u); session.commit()
        created.append(u)
        return u
    yield _make
    for u in created:
        session.delete(u)

Override fixtures from outside — define same name in nested conftest.py to override.

Async fixtures — covered in 06_pytest_asyncio.md.

Pitfalls

  • Session-scope fixture mutated by tests — bleeds between tests. Either function-scope it or always reset.
  • autouse + module-scope — runs once per module; teardown order across modules can surprise you.
  • Cyclic dependencies — pytest detects and fails clearly.
  • Fixture order with parametrize — pytest reorders tests to maximize fixture reuse. Tests run in unexpected order; never rely on test ordering for correctness.

Interview angle

  • Q: “What scopes does a fixture have?” — function/class/module/package/session.
  • Q: “What does autouse=True do, and when is it a bad idea?” — every test in scope gets it; bad when behavior is invisible from the test.
  • Follow-up: “How do you parametrize a fixture?” — params=[...], access via request.param.
  • Follow-up: “What’s indirect=True?” — passes parametrize values through the fixture function instead of directly.

See 02_pytest_concepts.md, 04_parametrize_patterns.md.