Skip to content

Chapter 15 · Advanced Parametrization ​

Chapter goals: master four advanced weapons — indirect parametrization, 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:

python
# 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.closed

At runtime every parameter triggers a full fixture setup/teardown cycle, while test IDs remain ["sqlite"], ["postgres"]. Three key points:

  1. The fixture name must match parametrize("db_conn", ...);
  2. Read the parameter inside the fixture via request.param;
  3. indirect can 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:

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

While stacking, pytest.param can mark or rename individual combinations:

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

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

bash
$ pytest -k "guest"           # Run only cases whose ID contains guest
$ pytest --collect-only -q    # Preview all generated case IDs

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

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

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

DimensionparametrizeHypothesis (Chapter 18)
Input sourceManually enumeratedRandomly generated from strategies
Good atKnown boundaries, regression cases, spec tablesDiscovering unknown boundaries (empty strings, huge numbers)
ReproducibilityFully deterministicPins failing examples with shrinking for reproduction
Failure triageInstantly see which parameter set failedRequires 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=True routes parameters through same-named fixtures; read raw values via request.param;
  • Stacked parametrize decorators form a Cartesian product; pytest.param marks/renames individual combos;
  • An ids list or idfn function generates readable IDs that pair with -k for 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 ​

  1. Write indirect parametrization for a send_email(to, subject) function: the to argument passes through a fixture that validates format, handling invalid-address cases with pytest.param plus a custom exception assertion.
  2. Convert one of Chapter 5's parametrized tests to an idfn version, then compare case IDs before/after using --collect-only -q.
  3. Implement pytest_generate_tests: when --full-matrix is present emit all 3×3 combinations, otherwise run only the 3 diagonal ones.

The next chapter formally upgrades today's hooks into custom plugins.