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, andpytest.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:
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) == expectedAt 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:
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) == expectedArguments 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:
@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:
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() == textpytest.param can also attach marks to a single row — the standard solution for "this particular dataset is known to be broken":
@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 == expected5.3 Multiple Parameters and Cartesian Products
parametrize supports several parameter names naturally, matched positionally:
@pytest.mark.parametrize(
"a, b, expected",
[
(2, 3, 5),
(-1, -2, -3),
(0, 0, 0),
],
)
def test_add(a, b, expected):
assert a + b == expectedStacking decorators produces a cartesian product — every value of every parameter combines multiplicatively:
@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 > 0When 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:
@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# 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 anids=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
- Write parametrized tests for
parse_iso_date(text) -> datetime.date: three valid inputs and two invalid ones (raisingValueError), usingpytest.raisesfor the invalid group plus explicitids. - Stack two
parametrizedecorators to verifyabs(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. - 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.