Chapter 15 · Advanced Parametrization
Chapter goals: master four advanced weapons —
indirectparametrization, stacked multiple parametrize decorators, custom test IDs, and dynamically generated parameters — and learn to choose between exhaustive parametrization and property-based testing.
15.1 indirect: Route Parameters Through a Fixture First
Chapter 5 showed that @pytest.mark.parametrize passes parameter values straight into the test function. Often, though, parameters aren't "values used directly" but recipes for building resources: the same test may need to run against both SQLite and PostgreSQL, where the parameter is just a database name and what should actually reach the test is a constructed connection object.
Set indirect=True, and pytest hands the values to a fixture of the same name; the fixture reads the raw value via request.param, processes it, and returns the result:
# test_indirect.py
import pytest
@pytest.fixture
def db_conn(request):
"""Receives the database name from parametrize and returns the matching connection object (faked here)."""
db_name = request.param # The raw argument from parametrize
conn = FakeConnection(db_name) # In real projects this is heavy work: opening connections, starting containers...
yield conn # Teardown: close after the test
conn.close()
class FakeConnection:
def __init__(self, name):
self.name = name
self.closed = False
def close(self):
self.closed = True
@pytest.mark.parametrize("db_conn", ["sqlite", "postgres"], indirect=True)
def test_query_works(db_conn):
# What arrives here is the fixture's return object, not the string itself
assert db_conn.name in ("sqlite", "postgres")
assert not db_conn.closedAt runtime every parameter triggers a full fixture setup/teardown cycle, while test IDs remain ["sqlite"], ["postgres"]. Three key points:
- The fixture name must match
parametrize("db_conn", ...); - Read the parameter inside the fixture via
request.param; indirectcan apply to only some arguments:indirect=["db_conn"](when there are multiple parameters).
Why not just if/else inside the test
Keeping "how resources are built" in fixtures and "how behavior is verified" in tests continues Chapter 6's DI idea: one assertion body can switch underlying implementations painlessly.
15.2 Stacking Multiple parametrize: The Cartesian Product
Stacking two parametrize decorators on one function produces the Cartesian product:
# test_cartesian.py
import pytest
@pytest.mark.parametrize("x", [0, 1])
@pytest.mark.parametrize("y", [2, 3])
def test_double_param(x, y):
# Produces x×y = 4 cases:
# [0-2] [0-3] [1-2] [1-3]
assert x + y > -1While stacking, pytest.param can mark or rename individual combinations:
# test_known_bug.py
import pytest
@pytest.mark.parametrize("browser", ["chromium", "firefox"])
@pytest.mark.parametrize(
"viewport",
[
"desktop",
pytest.param(
"mobile",
marks=pytest.mark.xfail(
reason="Mobile popup covers the button; known bug #1024"
),
id="mobile-known-bug",
),
],
)
def test_login_button_visible(browser, viewport):
assert check_button(browser, viewport)
def check_button(browser, viewport):
return viewport != "mobile-known-bug"This registers known-failing combinations explicitly as xfail; remove the marker once fixed, without polluting other combinations' results. Note: with stacked decorators, the decorator closest to the function varies fastest (the inner axis is the "fast axis"); ordering axes affects readability of failure output.
15.3 Custom Test IDs: ids and idfn
Default IDs are parameter reprs — complex objects become uninformative names like conn0, conn1. Two remedies:
# test_ids.py
import pytest
CASES = [
("admin", {"read", "write"}),
("guest", {"read"}),
]
def id_fn(val):
"""id function: receives each value in turn and returns a readable string; None means use the default ID."""
if isinstance(val, set):
return "+".join(sorted(val))
return str(val)
@pytest.mark.parametrize("role,perms", CASES, ids=id_fn)
def test_permissions(role, perms):
...You can also set id= on individual pytest.params (see 15.2), or pass a plain string list like ids=["admin-rw", "guest-r"]. IDs aren't cosmetic — they combine with -k expressions for precise filtering:
$ pytest -k "guest" # Run only cases whose ID contains guest
$ pytest --collect-only -q # Preview all generated case IDsWatch out
The length of an ids list must equal the number of parameters or pytest errors out; an idfn returning None means "use the default ID for this value" — that makes it more flexible than a fixed-length list of strings.
15.4 Parameterizing Classes and Modules as a Whole
parametrize is an ordinary marker and can sit directly on a test class, parameterizing every method within:
# test_class_level.py
import pytest
@pytest.mark.parametrize("n,expected", [(1, 1), (2, 4), (3, 9)])
class TestSquare:
def test_value(self, n, expected):
assert n ** 2 == expected
def test_type(self, n, expected):
assert isinstance(n ** 2, int)This yields six cases (three parameter sets × two methods), ideal when one input group must satisfy multiple invariants.
For more complex dynamic logic (say, deriving the parameter range from a command-line option), use the pytest_generate_tests hook:
# conftest.py or a test module
def pytest_generate_tests(metafunc):
"""Dynamically generate parameters at collection time for tests needing 'target'."""
if "target" in metafunc.fixturenames:
targets = ["fast", "slow"] if metafunc.config.getoption("--all") else ["fast"]
metafunc.parametrize("target", targets)metafunc.fixturenames reveals which parameters a test needs, and metafunc.parametrize expands cases during collection — the ultimate flexibility no static approach offers (Chapter 16 ties this into plugin hooks).
15.5 Parametrize vs Property-Based Testing: How to Choose
| Dimension | parametrize | Hypothesis (Chapter 18) |
|---|---|---|
| Input source | Manually enumerated | Randomly generated from strategies |
| Good at | Known boundaries, regression cases, spec tables | Discovering unknown boundaries (empty strings, huge numbers) |
| Reproducibility | Fully deterministic | Pins failing examples with shrinking for reproduction |
| Failure triage | Instantly see which parameter set failed | Requires shrinking to minimize the counterexample |
Practical advice: they complement each other. Use parametrization to pin down "known must-hold examples" and property-based tests to sweep "input spaces you never thought about". A sorting function deserves both a (input, expected) spec table and properties like "sorted(x) preserves length and is ordered".
Chapter Summary
indirect=Trueroutes parameters through same-named fixtures; read raw values viarequest.param;- Stacked
parametrizedecorators form a Cartesian product;pytest.parammarks/renames individual combos; - An
idslist oridfnfunction generates readable IDs that pair with-kfor precise filtering; - Putting the marker on a class parameterizes it wholesale;
pytest_generate_tests(metafunc)enables collection-time dynamic expansion; - Parametrization pins known cases; property-based testing explores unknown domains — complements, not substitutes.
🧪 随堂测验
点击你认为正确的选项。答错时会展示正确答案与原因解析。
1. With indirect=True, how does the fixture read the raw argument passed by parametrize?
2. A function stacks two parametrize decorators with three values each. How many cases are generated?
3. Regarding id_fn used as ids=id_fn, which statement is correct?
4. To decide a test’s parameter range dynamically at collection time based on a command-line option, use:
🛠️ Hands-on Practice
- Write indirect parametrization for a
send_email(to, subject)function: thetoargument passes through a fixture that validates format, handling invalid-address cases withpytest.paramplus a custom exception assertion. - Convert one of Chapter 5's parametrized tests to an
idfnversion, then compare case IDs before/after using--collect-only -q. - Implement
pytest_generate_tests: when--full-matrixis present emit all 3×3 combinations, otherwise run only the 3 diagonal ones.
The next chapter formally upgrades today's hooks into custom plugins.