Skip to content

Chapter 5 · Parametrized Tests ​

Chapter goals: collapse "same logic, many datasets" tests into a single spec with @pytest.mark.parametrize, and master multi-parameter forms, cartesian products, custom case IDs, and pytest.param.

5.1 Why Parametrization ​

To verify is_palindrome, the clumsy way is copy-pasting ten test functions; the parametrized way is one test body + one data table:

python
import pytest


@pytest.mark.parametrize(
    "text, expected",
    [
        ("level", True),
        ("hello", False),
        ("", True),
        ("A man, a plan, a canal: Panama", True),
    ],
)
def test_is_palindrome(text, expected):
    from strings import is_palindrome

    assert is_palindrome(text) == expected

At runtime pytest generates an independent case per row with node ids like test_is_palindrome[level] and test_is_palindrome[hello]. Each row passes or fails independently — one failing row doesn't stop the rest from being verified. That's exactly why parametrization beats looping asserts:

python
def test_bad_style():
    # anti-pattern: asserting inside a loop — the first failure aborts and later rows never get tested
    for text, expected in [("level", True), ("hello", False)]:
        assert is_palindrome(text) == expected

Arguments Are Not Copied

The official docs call this out: parameter values are passed as-is. If you pass a list or dict and mutate it inside the case, the mutation affects subsequent cases — be careful putting mutable objects in a data table (or generate fresh objects per row via a factory function).

5.2 Readable Failures: ids and pytest.param ​

Default case IDs are just concatenated argument values — ugly with long strings or objects. Two ways to improve:

python
@pytest.mark.parametrize(
    "text, expected",
    [
        pytest.param("level", True, id="simple-palindrome"),
        pytest.param("hello", False, id="plain-word"),
        pytest.param("", True, id="empty-string"),
        pytest.param("A man, a plan, a canal: Panama", True, id="classic-long"),
    ],
    ids=None,  # each row's id comes from its pytest.param
)
def test_is_palindrome_named(text, expected):
    ...

You can also supply an ids list or a function that derives IDs automatically:

python
def make_id(val):
    if isinstance(val, str):
        return val[:10]
    return str(val)


@pytest.mark.parametrize("text", ["apple", "banana"], ids=make_id)
def test_lower(text):
    assert text.lower() == text

pytest.param can also attach marks to a single row — the standard solution for "this particular dataset is known to be broken":

python
@pytest.mark.parametrize(
    "n, expected",
    [
        (1, 2),
        pytest.param(6 * 9, 42, marks=pytest.mark.xfail(reason="Hitchhiker's Guide joke"), id="deep-thought"),
        pytest.param(99, 100, marks=pytest.mark.slow),
    ],
)
def test_increment(n, expected):
    assert n + 1 == expected

5.3 Multiple Parameters and Cartesian Products ​

parametrize supports several parameter names naturally, matched positionally:

python
@pytest.mark.parametrize(
    "a, b, expected",
    [
        (2, 3, 5),
        (-1, -2, -3),
        (0, 0, 0),
    ],
)
def test_add(a, b, expected):
    assert a + b == expected

Stacking decorators produces a cartesian product — every value of every parameter combines multiplicatively:

python
@pytest.mark.parametrize("x", [1, 10])
@pytest.mark.parametrize("y", [2, 5])
def test_multiply(x, y):
    # generates 4 cases: [2-1] [2-10] [5-1] [5-10]
    assert x * y > 0

When stacking, the lower decorator varies faster. Cartesian products suit properties like "no combination crashes", but counts explode easily (3×3×3 = 27) — use sparingly.

5.4 Class-Level and Module-Level Parametrization ​

A parametrize mark can sit on a class so every test receives the parameters; at module level assign it to the conventional pytestmark variable:

python
@pytest.mark.parametrize("n, expected", [(1, 2), (3, 4)])
class TestIncrement:
    def test_simple(self, n, expected):
        assert n + 1 == expected

    def test_weird(self, n, expected):
        assert (n * 1) + 1 == expected
python
# module-level: every test in this file receives n/expected
import pytest

pytestmark = pytest.mark.parametrize("n, expected", [(1, 2), (3, 4)])

5.5 When to Parameterize vs Split ​

Judge by failure semantics:

  • Multiple datasets verifying the same behavioral rule → parametrize (on failure you only care which row broke);
  • Different behaviors / different exception paths → separate test functions (on failure you care which behavior broke).

Additionally pytest 9 offers subtests as an alternative to parametrization (fitting "soft assertions in a loop where failures don't abort" scenarios); see the official How to use subtests documentation.

5.6 Chapter Summary ​

  • @pytest.mark.parametrize("a,b", [(..), ..]): one logic × data table = N independent cases, better than looped asserts;
  • Case IDs are customizable via pytest.param(id=...) or an ids= function; non-ASCII is escaped by default;
  • pytest.param(marks=...) attaches skip/xfail to individual rows;
  • Stacking decorators yields cartesian products — beware combinatorial explosion;
  • Parameters pass by reference; mutating them affects later cases.

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. Compared to asserting inside a for loop, what is the core advantage of parametrization?

2. Stacking two @pytest.mark.parametrize decorators (one with 2 values, one with 3) yields how many cases?

3. How do you xfail one specific row of a parametrize table?

4. What does the official docs warn about passing mutable objects (like lists) into parametrize?

🛠️ Hands-On Exercises ​

  1. Write parametrized tests for parse_iso_date(text) -> datetime.date: three valid inputs and two invalid ones (raising ValueError), using pytest.raises for the invalid group plus explicit ids.
  2. Stack two parametrize decorators to verify abs(x * y) never goes below zero for x∈{-1, 0, 1}, y∈{2, 3}; write out all six node IDs on paper first, then run to confirm.
  3. Convert Chapter 3's aggregated-schema-assertion test into a parametrized version where each row is a "bad field + expected error message".

With data-driven testing ready, the next chapter begins pytest's core dependency-injection mechanism: fixtures.