Перейти к содержанию
Learning Platform
Глоссарий Troubleshooting
Урок 09.03 · 22 мин
Средний
pytestparametrize@pytest.mark.parametrizeidspytest.paramdata-driven testingTest IDsRun-on-Your-Machine

pytest.mark.parametrize и ids — data-driven testing

Parametrize — pytest-механизм для data-driven testing: один test function исполняется много раз с разными inputs. Это не копирование тестов — это таблица test cases в одном месте, что даёт сжатый код + readable failure logs.

В этом уроке:

  1. Why parametrize — DRY для tabular tests.
  2. @pytest.mark.parametrize basic syntax — string of param names + list of value tuples.
  3. ids= keyword — readable test IDs (vs default auto-generated).
  4. pytest.param(value, marks=...) — per-row marks (skip / xfail / custom).
  5. Parallel framingtestCases array в browser function-call mode IS parametrize table.
  6. 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 делает:

  1. Видит @pytest.mark.parametrize('a,b,expected', [...]) decorator.
  2. Для каждого tuple в list — generates new test invocation.
  3. 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).
  4. Failure log показывает which row failed.

Cite: pytest 8 docs — parametrize; _pytest/python.pyMetafunc.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 IDsids=['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 reports XPASS (warning). Если fails — XFAIL (PASSED-equivalent). Use case: known bug under fix, regression marker.
  • Custom markermarks=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
    }
  ]
}

Маппинг testCasespytest.mark.parametrize:

testCases fieldpytest.mark.parametrize equivalent
idids=[...] element
descriptioncomment / docstring (not native, but common)
inputargument tuple element
expectedOutputexpected 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

TIP

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.py

Expected 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.py

Expected:

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.


Ключевые выводы

  1. @pytest.mark.parametrize — data-driven testing: один test function × N input rows = N test invocations. DRY для tabular tests.
  2. Syntaxparametrize('a,b,expected', [(2,3,5), (0,0,0)]). Single param — list of values. Multiple — list of tuples. Stacked decorators = Cartesian product.
  3. ids=['...', '...'] — readable test IDs vs auto-generated. Production rule: always use ids для readable failure logs + -k filter filtering benefit.
  4. pytest.param(value, marks=...) — per-row marks (skip / xfail / custom). Combine с id='...' для readable + selectively-managed rows.
  5. testCases array в browser function-call mode IS parametrize table — same shape, разные surfaces. id/input/expectedOutput/matchModeids[i]/value-tuple/expected/assert-mode.
  6. 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').

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 7