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=Truedo, 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 viarequest.param. - Follow-up: “What’s
indirect=True?” — passes parametrize values through the fixture function instead of directly.