Skip to content

Chapter 8 · Advanced Fixture Patterns ​

Chapter goals: master "factory fixtures", the request object, fixture parametrization, and the usefixtures mark — 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:

python
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:

python
@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 up

Mnemonic

"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:

AttributeMeaning
request.paramCurrent value when the fixture is parametrized (see 8.3)
request.moduleThe requesting test module
request.cls / request.instanceThe requesting class/instance
request.functionThe 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:

python
# 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()
python
# 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 == 250

getfixturevalue supports deciding dependencies at runtime (ordinary parameters fix dependencies at collection time):

python
@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:

python
# 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
python
# content of test_repo.py
def test_find_user(db):
    rows = db.query("SELECT * FROM users")
    assert rows[0]["id"] == 1

The 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:

python
@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.param

8.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:

python
# 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)
python
# 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

  1. It cannot be applied to fixture functions — dependencies between fixtures must be declared through parameter signatures;
  2. 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 request object provides param/module/function/addfinalizer/getfixturevalue context capabilities;
  • @pytest.fixture(params=...) re-runs all dependent tests per parameter — the weapon for "many implementations, one contract";
  • usefixtures fits 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 ​

  1. Build a factory fixture make_users for a fake UserRepository: create N users on demand and clear the repository in teardown.
  2. Use fixture parametrization so the same test_stack_behavior runs on both list and collections.deque implementations.
  3. Rework this chapter's cleandir into "temp dir + restore working directory", then write one test with parameter injection and one with usefixtures to feel the difference.

Next chapter sweeps pytest's built-in Swiss-army knives — tmp_path, capsys, caplog, monkeypatch, cache: Chapter 9.