Fixtures, parametrize and scope
The three pytest concepts an interviewer will actually probe. All three are about the same thing: expressing test setup as a dependency rather than as code you repeat.
Fixtures are dependencies by name
A fixture is a function whose name is a parameter. pytest resolves it, passes
the value in, and runs whatever follows the yield afterwards.
@pytest.fixture
def session():
s = Session(engine)
yield s # the test runs here
s.rollback() # teardown, even if it failed
s.close()def test_creates_user(session):
session.add(User(email="a@b.c"))
assert session.query(User).count() == 1The property that matters is that fixtures compose — a fixture can request another fixture, and pytest builds the graph:
@pytest.fixture
def admin(session): # depends on session
return make_user(session, role="admin")
def test_admin_can_delete(admin, session):
# session resolved once, shared
...session is created once for that test and both fixtures receive the same
object. That is what setUp cannot express: it runs for every test in the
class whether or not the test needs it, and it cannot depend on another
setUp.
conftest.py is where shared fixtures live. Placement is the scoping
mechanism — a conftest.py applies to its directory and everything below it,
so test infrastructure is scoped by area without any imports.
Parametrize turns one test into many
@pytest.mark.parametrize(
"raw,expected",
[
("2026-01-01", date(2026, 1, 1)),
("01/01/2026", date(2026, 1, 1)),
pytest.param("", None, id="empty"),
pytest.param("nope", None, marks=pytest.mark.xfail),
],
)
def test_parse_date(raw, expected):
assert parse_date(raw) == expectedEach tuple is a separately reported test, so a failure names the input:
test_parse_date[empty]. A for loop inside one test gives you neither — it
stops at the first failure and the report says only that the test failed.
pytest.param is how you attach an id or a mark to a single case, which is the
answer to “how do you skip just one input”.
Scope is the performance lever
| Scope | Created |
|---|---|
function |
per test (default) |
class |
per test class |
module |
per file |
package |
per package |
session |
once per run |
Choose the widest scope that is still safe. A suite that takes twenty minutes is almost always building something expensive per test:
@pytest.fixture(scope="session")
def pg():
with PostgresContainer() as c: # once, ~3s
run_migrations(c.url)
yield c
@pytest.fixture
def session(pg): # per test, ~1ms
conn = pg.connect()
tx = conn.begin()
yield Session(bind=conn)
# isolation restored
tx.rollback()That pairing is the standard answer: a session-scoped container for the cost, a function-scoped transaction for the isolation. You get both.
Gotcha: a widened fixture that mutates shared state produces tests that pass alone and fail in a suite, or pass in one order and fail in another. Run with
-p no:randomlyoff — that is, let the order vary — so you find the coupling rather than depending on it.
autouse=True applies a fixture without naming it. Use it sparingly, for
things every test genuinely needs (freezing the clock, resetting a registry);
overused, it makes tests depend on setup that is invisible at the call site.
The rest, briefly
with pytest.raises(ValueError, match="negative"):
charge(-1)
assert total == pytest.approx(0.1 + 0.2)match is what stops pytest.raises passing on the wrong ValueError, and
approx is what stops float equality producing a flaky test.
Related
Interview angle 5
- “What makes a fixture better than setUp?” - explicit dependency by parameter name, composability (fixtures requesting fixtures, resolved once per test), and scoping, so an expensive resource is created once per session while cheap state is per test.
- “Which scope do you choose?” - the widest that’s still safe. A database container at session scope with a per-test transaction rollback gives fast, isolated tests; recreating the container per test makes the suite unusable.
- “How do you test many input combinations?” -
@pytest.mark.parametrize, which produces one reported test per case, so a failure names the exact input. A loop inside one test hides which case failed and stops at the first. - “What’s
conftest.pyfor?” - fixtures shared across a directory without importing. Placement matters: it applies to its directory and below, which is how you scope test infrastructure by area. - “A test passes alone and fails in the suite. Where do you look?” - shared state in a fixture whose scope is wider than
function. Something mutates it and does not restore it, so the failure depends on execution order.