Chapter 8 · Advanced Fixture Patterns
Chapter goals: master "factory fixtures", the
requestobject, fixture parametrization, and theusefixturesmark — so you can build reusable test infrastructure for arbitrarily complex resources.
8.1 Factory Fixtures: Inject Once, Generate Many
Chapter 7's fixtures each produced a single object. Real scenarios often need multiple resources of the same kind within one test — adding three products to a cart, inviting three users to a group. A fixture hardwired to one return value won't cut it.
The official solution is the factory as fixture pattern: the fixture returns not the data but a function that generates data:
import pytest
@pytest.fixture
def make_customer_record():
# returns the record-generating factory itself, not a record
def _make_customer_record(name):
return {"name": name, "orders": []}
return _make_customer_record
def test_customer_records(make_customer_record):
c1 = make_customer_record("Lisa")
c2 = make_customer_record("Mike")
c3 = make_customer_record("Meredith")
assert {c["name"] for c in (c1, c2, c3)} == {"Lisa", "Mike", "Meredith"}Factory functions take arbitrary parameters and combine with yield for batch cleanup — the pattern's most practical form:
@pytest.fixture
def make_customer_record():
created_records = []
def _make(name):
record = {"name": name, "orders": []}
created_records.append(record) # track every object created
return record
yield _make # provide the factory for the duration of the test
for record in created_records: # destroy everything during teardown
record.clear()
def test_orders(make_customer_record):
rec = make_customer_record("Alice")
rec["orders"].append({"sku": "A1", "qty": 2})
assert len(rec["orders"]) == 1
# after the test, every record the factory created gets cleaned upMnemonic
"Need one? Fixture. Need many? Factory." The factory pattern hands the "how many" decision back to the test function while cleanup stays centralized in the fixture.
8.2 The request Object: Fixtures Aware of Context
A fixture can declare a special parameter request — the request context pytest injects. Common attributes:
| Attribute | Meaning |
|---|---|
request.param | Current value when the fixture is parametrized (see 8.3) |
request.module | The requesting test module |
request.cls / request.instance | The requesting class/instance |
request.function | The requesting test function |
request.addfinalizer(fn) | Register a teardown callback |
request.getfixturevalue("name") | Dynamically fetch another fixture by name |
A classic use is reading configuration from the test module, letting one fixture serve differently-configured tests:
# content of conftest.py
import smtplib
import pytest
@pytest.fixture(scope="module")
def smtp_connection(request):
# read the smtpserver attribute from the consuming module, default otherwise
server = getattr(request.module, "smtpserver", "smtp.gmail.com")
conn = smtplib.SMTP(server, 587, timeout=5)
yield conn
print(f"finalizing {conn} ({server})")
conn.close()# content of test_anothersmtp.py
smtpserver = "mail.python.org" # read by the conftest fixture
def test_showhelo(smtp_connection):
code, _ = smtp_connection.noop()
assert code == 250getfixturevalue supports deciding dependencies at runtime (ordinary parameters fix dependencies at collection time):
@pytest.fixture
def driver(request):
browser = getattr(request.module, "BROWSER", "chromium")
# dynamically fetch the same-named fixture (chromium/firefox must be defined)
impl = request.getfixturevalue(browser)
yield impl
impl.quit()8.3 Parametrizing Fixtures: One Test × N Implementations
Chapter 5 parametrized test functions; fixtures can be parametrized too — via @pytest.fixture(params=[...]). Each parameter value executes the fixture once, and every test depending on it automatically re-runs. Values arrive via request.param:
# content of conftest.py
import pytest
@pytest.fixture(params=["sqlite", "postgres"])
def db(request):
# runs once per database; request.param holds the current value
if request.param == "sqlite":
engine = FakeSQLiteEngine()
else:
engine = FakePostgresEngine()
yield engine
engine.drop_all()
class FakeSQLiteEngine:
def query(self, sql):
return [{"id": 1}]
def drop_all(self): ...
class FakePostgresEngine(FakeSQLiteEngine):
pass# content of test_repo.py
def test_find_user(db):
rows = db.query("SELECT * FROM users")
assert rows[0]["id"] == 1The run produces two test items: test_find_user[sqlite] and test_find_user[postgres]. Without changing the test code at all you've verified "business logic behaves identically across stores" — the unique value of fixture parametrization versus test parametrization: it naturally targets "multiple implementations, one contract".
Test IDs default to the parameter's string form; customize with ids, or mark individual parameters via pytest.param:
@pytest.fixture(
params=[
"v1", # runs normally
pytest.param("legacy", marks=pytest.mark.xfail(reason="legacy format no longer supported")),
],
ids=["current", "old"],
)
def api_version(request):
return request.param8.4 usefixtures: Dependencies Without Touching Signatures
Sometimes a test only needs a side effect (like switching to an empty directory) and doesn't want the fixture's value. You could stuff an unused parameter into the signature — or use usefixtures more elegantly:
# content of conftest.py
import os
import tempfile
import pytest
@pytest.fixture
def cleandir():
with tempfile.TemporaryDirectory() as newpath:
old_cwd = os.getcwd()
os.chdir(newpath)
yield
os.chdir(old_cwd)# content of test_setenv.py
import os
import pytest
@pytest.mark.usefixtures("cleandir")
class TestDirectoryInit:
def test_cwd_starts_empty(self):
assert os.listdir(os.getcwd()) == []
with open("myfile", "w", encoding="utf-8") as f:
f.write("hello")
def test_cwd_again_starts_empty(self):
assert os.listdir(os.getcwd()) == []usefixtures stacks multiple fixtures (@pytest.mark.usefixtures("a", "b")), applies module-wide via pytestmark = pytest.mark.usefixtures("cleandir"), and even project-wide via the config file's usefixtures option.
Two Limits of usefixtures
- It cannot be applied to fixture functions — dependencies between fixtures must be declared through parameter signatures;
- Since it yields no return value, fixture data still requires parameter injection.
8.5 Chapter Summary
- Factory fixtures return "generator functions" — ideal for many-per-test plus unified cleanup;
- The
requestobject providesparam/module/function/addfinalizer/getfixturevaluecontext capabilities; @pytest.fixture(params=...)re-runs all dependent tests per parameter — the weapon for "many implementations, one contract";usefixturesfits side-effect-only needs but cannot be used inside fixtures.
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. In the "factory as fixture" pattern, what should the fixture return?
2. Inside @pytest.fixture(params=["a", "b"]), how do you access the current parameter value?
3. Which statement about @pytest.mark.usefixtures is FALSE?
4. To load another fixture dynamically at runtime based on a condition, use:
🛠️ Hands-On Exercises
- Build a factory fixture
make_usersfor a fakeUserRepository: create N users on demand and clear the repository in teardown. - Use fixture parametrization so the same
test_stack_behaviorruns on bothlistandcollections.dequeimplementations. - Rework this chapter's
cleandirinto "temp dir + restore working directory", then write one test with parameter injection and one withusefixturesto feel the difference.
Next chapter sweeps pytest's built-in Swiss-army knives —
tmp_path,capsys,caplog,monkeypatch,cache: Chapter 9.