pytest.mark.parametrize и ids — data-driven testing
Parametrize — pytest-механизм для data-driven testing: один test function исполняется много раз с разными inputs. Это не копирование тестов — это таблица test cases в одном месте, что даёт сжатый код + readable failure logs.
В этом уроке:
- Why parametrize — DRY для tabular tests.
@pytest.mark.parametrizebasic syntax — string of param names + list of value tuples.ids=keyword — readable test IDs (vs default auto-generated).pytest.param(value, marks=...)— per-row marks (skip / xfail / custom).- Parallel framing —
testCasesarray в browser function-call mode IS parametrize table. - Run-on-Your-Machine — pytest -v parametrize demo.
Why parametrize — DRY для tabular tests
Без parametrize — каждый test case — отдельная функция:
def add(a: int, b: int) -> int:
return a + b
def test_add_positive():
assert add(2, 3) == 5
def test_add_zero():
assert add(0, 0) == 0
def test_add_negative():
assert add(-1, 1) == 0
def test_add_large():
assert add(1_000_000, 2_000_000) == 3_000_000
Проблема: для каждого нового case — новая функция. 20 cases = 20 functions. Сложно scan’ить, каждое имя duplicates pattern.
С parametrize — таблица в одном месте:
import pytest
@pytest.mark.parametrize('a,b,expected', [
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(1_000_000, 2_000_000, 3_000_000),
])
def test_add(a: int, b: int, expected: int) -> None:
assert add(a, b) == expected
Что pytest делает:
- Видит
@pytest.mark.parametrize('a,b,expected', [...])decorator. - Для каждого tuple в list — generates new test invocation.
- Names invocations:
test_add[2-3-5],test_add[0-0-0],test_add[-1-1-0],test_add[1000000-2000000-3000000](default — auto-generated из values). - Failure log показывает which row failed.
Cite: pytest 8 docs — parametrize; _pytest/python.py — Metafunc.parametrize создаёт tests for each row.
Basic syntax — string of param names + list of value tuples
Полный signature:
@pytest.mark.parametrize(
argnames, # str: 'a,b,expected' — comma-separated parameter names
argvalues, # list: [(2,3,5), (0,0,0)] — tuples (or single values)
ids=..., # optional: list of human-readable test IDs
indirect=..., # advanced: route values через fixtures
scope=..., # advanced: pin scope для parametrized fixtures
)
def test_X(a, b, expected): ...
Single parameter — list of values (not tuples):
@pytest.mark.parametrize('value', [0, 1, 100, -1, 1_000_000])
def test_is_positive(value: int) -> None:
assert (value > 0) == is_positive(value)
Multiple parameters — string 'a,b,c' + list of tuples:
@pytest.mark.parametrize('input_str,expected', [
('42', 42),
(' 42 ', 42), # whitespace stripped
('-5', -5),
('0', 0),
])
def test_parse_int(input_str: str, expected: int) -> None:
assert parse_int(input_str) == expected
Stack — два decorators = Cartesian product:
@pytest.mark.parametrize('a', [1, 2])
@pytest.mark.parametrize('b', [10, 20])
def test_combinations(a: int, b: int) -> None:
# Runs 4 times: (a=1,b=10), (a=1,b=20), (a=2,b=10), (a=2,b=20)
assert a + b > 0
Pragmatic rule: используйте один decorator для logically related rows (same operation + various inputs). Stacked decorators — для independent axes (e.g., parametrize input + parametrize Python version).
ids= — readable test IDs (vs default auto-generated)
Default — pytest generates IDs из values:
@pytest.mark.parametrize('a,b,expected', [(2,3,5), (0,0,0), (-1,1,0)])
def test_add(a, b, expected): ...
# Auto-generated names:
# test_add[2-3-5]
# test_add[0-0-0]
# test_add[-1-1-0]
Хорошо для small ints. Плохо для long strings, complex objects, или semantic значимость.
Custom IDs — ids=['name1', 'name2', ...] matching argvalues order:
@pytest.mark.parametrize(
'a,b,expected',
[(2, 3, 5), (0, 0, 0), (-1, 1, 0)],
ids=['positive', 'zero', 'negative'],
)
def test_add(a, b, expected):
assert add(a, b) == expected
# Names:
# test_add[positive]
# test_add[zero]
# test_add[negative]
Output:
test_calc.py::test_add[positive] PASSED
test_calc.py::test_add[zero] PASSED
test_calc.py::test_add[negative] PASSED
Failure logs читаются как specification — test_add[zero] FAILED сразу understandable, не нужно decoder values.
Pragmatic rule: для production parametrized tests всегда use ids= — readable failure logs стоят 30 секунд на write.
pytest.param(value, marks=...) — per-row marks
Standalone pytest.param обёртка позволяет attach marks (skip, xfail, custom) к отдельной row:
import pytest
@pytest.mark.parametrize('input_str,expected', [
('42', 42),
('-5', -5),
pytest.param('abc', None, marks=pytest.mark.skip(reason='not implemented yet')),
pytest.param('999999999999', None, marks=pytest.mark.xfail(reason='overflow case — known limitation')),
])
def test_parse_int(input_str: str, expected: int) -> None:
assert parse_int(input_str) == expected
Marks:
@pytest.mark.skip(reason=...)— row skipped, prints reason. Use case: feature not yet implemented.@pytest.mark.xfail(reason=...)— row expected to fail. Если passes — pytest reportsXPASS(warning). Если fails —XFAIL(PASSED-equivalent). Use case: known bug under fix, regression marker.- Custom marker —
marks=pytest.mark.slow(requires registration вpyproject.toml). - Combine ids:
pytest.param('abc', None, marks=..., id='invalid_format').
Empirical запуск:
test_parse.py::test_parse_int[42-42] PASSED
test_parse.py::test_parse_int[-5--5] PASSED
test_parse.py::test_parse_int[abc-None] SKIPPED (not implemented yet)
test_parse.py::test_parse_int[999999999999-None] XFAIL (overflow case)
pytest.param — единственный способ attach marks per-row (не на весь decorator).
Cite: pytest 8 docs — pytest.param.
Parallel framing — testCases IS parametrize table
В browser challenges Module 08 (function-call mode), testCases array structure:
{
"testCases": [
{
"id": "tc1",
"description": "Valid int parse",
"input": "'42'",
"expectedOutput": "42",
"matchMode": "exact",
"hidden": false
},
{
"id": "tc2",
"description": "Empty string fails",
"input": "''",
"expectedOutput": "ValidationError: empty",
"matchMode": "exact",
"hidden": false
},
{
"id": "tc3",
"description": "Negative parse",
"input": "'-5'",
"expectedOutput": "-5",
"matchMode": "exact",
"hidden": true
}
]
}
Маппинг testCases ↔ pytest.mark.parametrize:
testCases field | pytest.mark.parametrize equivalent |
|---|---|
id | ids=[...] element |
description | comment / docstring (not native, but common) |
input | argument tuple element |
expectedOutput | expected value (асserted в test) |
matchMode='exact'/'contains' | assert actual == expected / assert expected in actual |
hidden: true | (no direct equivalent — pytest всегда показывает all cases) |
Это — тот же pattern, разные surfaces. Browser test runner (testRunner.ts:buildPyHarness) генерирует __test_result = solve(<input>); print(__test_result) — pytest equivalent был бы result = solve(input); assert result == expected.
Pragmatic insight: Module 08 challenges (lesson 04 typed exception, lesson 05 mocks) педагогически ставит вас в позицию pytest author: вы пишете solve() (= test function body), platform runs testCases (= parametrize rows). Когда напишете local pytest test_X.py, тот же mental model применяется.
Cite: src/lib/testRunner.ts lines 70-77; _pytest/python.py:Metafunc.parametrize.
Run-on-Your-Machine — parametrize demo
Run-on-Your-Machine: pytest -v с parametrize + ids=
Установите pytest (если ещё не из урока 02):
pip install pytestСоздайте test_calc.py:
# test_calc.py
import pytest
def add(a: int, b: int) -> int:
return a + b
@pytest.mark.parametrize(
'a,b,expected',
[
(2, 3, 5),
(0, 0, 0),
(-1, 1, 0),
(1_000_000, 2_000_000, 3_000_000),
],
ids=['positive', 'zero', 'negative', 'large'],
)
def test_add(a: int, b: int, expected: int) -> None:
assert add(a, b) == expectedЗапустите:
pytest -v test_calc.pyExpected output:
test_calc.py::test_add[positive] PASSED [ 25%]
test_calc.py::test_add[zero] PASSED [ 50%]
test_calc.py::test_add[negative] PASSED [ 75%]
test_calc.py::test_add[large] PASSED [100%]
============================== 4 passed in 0.02s ==============================Что мы видим:
- 4 invocations одной test function — каждая получила свою row.
- Custom IDs (
positive,zero,negative,large) — readable. - 0 boilerplate — без parametrize пришлось бы написать 4 separate test functions.
Bonus — попробуйте pytest -v -k positive (filter by ID substring):
pytest -v -k positive test_calc.pyExpected:
test_calc.py::test_add[positive] PASSED [100%]Только positive row выполнился. -k filter работает на test ID — ещё один benefit ids=.
Recipe — parametrize с pytest.param + ids
End-to-end recipe для production:
# test_validate_age.py
import pytest
class ValidationError(Exception):
"""Domain validation error."""
def validate_age(s: str) -> int:
"""Parse age. Raises ValidationError on invalid."""
try:
n = int(s)
except ValueError:
raise ValidationError(f'invalid age: {s!r}')
if not 0 <= n <= 150:
raise ValidationError(f'age out of range: {n}')
return n
@pytest.mark.parametrize(
'input_str,expected',
[
# Valid cases
pytest.param('42', 42, id='middle_aged'),
pytest.param('0', 0, id='boundary_low'),
pytest.param('150', 150, id='boundary_high'),
# Future cases (skipped)
pytest.param('forty', None, marks=pytest.mark.skip(reason='spelled-out parsing — v2'), id='spelled_out'),
],
)
def test_validate_age_valid(input_str: str, expected: int) -> None:
assert validate_age(input_str) == expected
@pytest.mark.parametrize(
'invalid_input',
[
pytest.param('-1', id='negative'),
pytest.param('151', id='over_max'),
pytest.param('abc', id='not_a_number'),
pytest.param('', id='empty_string'),
],
)
def test_validate_age_raises(invalid_input: str) -> None:
with pytest.raises(ValidationError): # ← lesson 04 preview
validate_age(invalid_input)
Output:
test_validate_age_valid[middle_aged] PASSED
test_validate_age_valid[boundary_low] PASSED
test_validate_age_valid[boundary_high] PASSED
test_validate_age_valid[spelled_out] SKIPPED (spelled-out parsing — v2)
test_validate_age_raises[negative] PASSED
test_validate_age_raises[over_max] PASSED
test_validate_age_raises[not_a_number] PASSED
test_validate_age_raises[empty_string] PASSED
==================== 7 passed, 1 skipped in 0.03s ====================
8 test invocations, 2 functions, полное coverage of valid + invalid paths. Это — типичная parametrize structure в production-grade Python tests.
Ключевые выводы
@pytest.mark.parametrize— data-driven testing: один test function × N input rows = N test invocations. DRY для tabular tests.- Syntax —
parametrize('a,b,expected', [(2,3,5), (0,0,0)]). Single param — list of values. Multiple — list of tuples. Stacked decorators = Cartesian product. ids=['...', '...']— readable test IDs vs auto-generated. Production rule: always useidsдля readable failure logs +-k filterfiltering benefit.pytest.param(value, marks=...)— per-row marks (skip / xfail / custom). Combine сid='...'для readable + selectively-managed rows.testCasesarray в browser function-call mode IS parametrize table — same shape, разные surfaces.id/input/expectedOutput/matchMode≡ids[i]/value-tuple/expected/assert-mode.- Cite:
_pytest/python.py:Metafunc.parametrize— internal generator of test invocations from parametrize args.
Дальше — pytest.raises (урок 04): testing exceptional paths, cross-link M07 урок 06 (PYTH-09 typed exceptions). Browser challenge py-m08-04-code-1 использует function-call mode для typed exception testing — параллель pytest’s pytest.raises(ZeroDivisionError, match='cannot divide by zero').