LESSON 166 · PRACTICAL PYTHON30 min

pytest.mark.parametrize: test many cases with one function

Design a small table of input-output cases and run it as separate named pytest cases in a local project.

Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons

Video lesson: pytest.mark.parametrize: test many cases with one function

The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube

Read the video transcript

Select a timestamp to watch that moment on YouTube.

  1. 00:00

    pytest.mark.parametrize turns one test function into a separate test invocation for each row of case data. This removes copy-pasted tests while keeping each input visible in the results. The decorator names the parameters, then supplies tuples with values for those names. For example, @pytest.mark.parametrize('text, expected', [('0', 0), ('12', 12)]) runs the test body twice. Today we will test the claim with a tiny example, inspect a misleading first attempt, and finish with a verification case. Design a small table of input-output cases and run it as separate named pytest cases in a local project.

  2. 00:42

    A useful case table covers boundaries and representative inputs, not only the happy path. The local example tests zero, a multi- digit value, surrounding whitespace, negative input, and invalid text. It separates successful values from inputs expected to raise ValueError so that each test has one clear assertion. The ids argument gives human-readable names in pytest output, making a failure easier to locate. The first run is intentionally incomplete or incorrect. Each new input requires another duplicated function. Read the actual output before editing the code; a familiar-looking command can still

  3. 01:22

    answer the wrong question. Parametrization begins with a clear table of inputs and expected outputs. A tuple ('12', 12) means passing the string twelve to the parser should return the integer twelve. Include zero because it is a boundary, a multi-digit value because it is common, and surrounding spaces because int accepts them. Keep the table small enough that each row has a reason to exist. The browser example runs this table with a plain Python comprehension. That confirms the production function and each expected value without depending on pytest inside the page.

  4. 02:02

    When you move to a local test file, use the same rows in @pytest.mark.parametrize('text, expected', cases). The decorator produces one test invocation per row rather than one loop with a single report. We use a second run to make the cause visible. The printed values are small enough to check by hand. If they disagree with your prediction, pause and identify the assumption that changed. A test suite earns its value when it finds a mismatch. The deliberately incorrect expectation in this example says text '2' should become 3. The parser returns 2, so the assertion fails. A plain loop would stop at that row.

  5. 02:44

    With pytest parametrize, the row is reported as a separate failed case while other rows can still run. Use ids such as zero, two-digits, and spaces to make results understandable without reading raw tuple representations. IDs describe the scenario; they do not alter test behavior. Keep production code outside the test body, and avoid adding branching logic to the test just to make one row pass. If a row reveals a real contract mismatch, correct the function or expectation. Now compare the corrected run with the earlier one. The important change is the rule encoded in the operation, not merely the final display.

  6. 03:26

    Run the code and inspect the returned values as well as their interpretation. pytest is a third-party local test runner. Save the complete example as test_parse_numbers.py, install it with python -m pip install pytest, then run python -m pytest -q test_parse_numbers.py. The website's browser Python console does not install or launch pytest. Its practice task instead builds and checks the same case table with ordinary Python, so it works without a local test runner. That exercise is the data preparation for the decorator, not a claim that the browser executed pytest.

  7. 04:06

    A parser also has an error contract. Negative numbers are rejected by our function, while letters and empty text fail at int conversion. All three raise ValueError, but for different reasons. A local pytest file can parametrize these invalid inputs and put the call inside with pytest.raises(ValueError). That creates a separate check for every invalid scenario. The browser demonstration uses try/except only to make the three outcomes visible without a test runner. Do not write a bare except in production tests, because it can hide unrelated programming errors. The complete local example at the top of this page uses pytest.raises and names

  8. 04:48

    each invalid row. Install pytest in a local environment, run python -m pytest -q, and inspect individual case IDs when one fails. The final run checks a different condition so a special-case answer cannot pass unnoticed. Try the linked browser task and use the guide when you need a smaller step. The source links on the lesson page give the complete API behavior. Avoid a single test that loops over all rows when diagnosing a large table locally: the first failing assertion stops that loop, while parametrize reports each row as an individual case.

  9. 05:28

    Keep tests deterministic and small. If cases need setup, use fixtures rather than hiding side effects inside the data table. A test case should state the contract and leave production code separate from test definitions. When the local test runner shows a failure, read the case ID together with the assertion diff. An ID such as spaces tells you the row involved without opening the entire test table. Keep the expected value literal and independent from the implementation: computing the expected result by calling the same function would make the test circular. Parameter tables can cover many inputs, but more rows are not automatically

  10. 06:08

    better. Include boundaries, invalid classes, and previously observed bugs. For every row, ask what behavior it protects and remove redundant cases that add run time without a new assertion.

Understand the concept

pytest.mark.parametrize turns one test function into a separate test invocation for each row of case data. This removes copy-pasted tests while keeping each input visible in the results. The decorator names the parameters, then supplies tuples with values for those names. For example, @pytest.mark.parametrize('text, expected', [('0', 0), ('12', 12)]) runs the test body twice.

A useful case table covers boundaries and representative inputs, not only the happy path. The local example tests zero, a multi-digit value, surrounding whitespace, negative input, and invalid text. It separates successful values from inputs expected to raise ValueError so that each test has one clear assertion. The ids argument gives human-readable names in pytest output, making a failure easier to locate.

pytest is a third-party local test runner. Save the complete example as test_parse_numbers.py, install it with python -m pip install pytest, then run python -m pytest -q test_parse_numbers.py. The website's browser Python console does not install or launch pytest. Its practice task instead builds and checks the same case table with ordinary Python, so it works without a local test runner. That exercise is the data preparation for the decorator, not a claim that the browser executed pytest.

Avoid a single test that loops over all rows when diagnosing a large table locally: the first failing assertion stops that loop, while parametrize reports each row as an individual case. Keep tests deterministic and small. If cases need setup, use fixtures rather than hiding side effects inside the data table. A test case should state the contract and leave production code separate from test definitions.

  • parametrize creates one test invocation per row
  • decorator parameter names match tuple positions
  • ids make failures readable
  • pytest.raises checks expected exceptions

See it step by step

Read the code, predict the output, then compare it with the result.

01. Build a plain case table

PYTHON
def parse_nonnegative(text):
    number = int(text)
    if number < 0:
        raise ValueError('negative number')
    return number
cases = [('0', 0), ('12', 12), (' 7 ', 7)]
print([(text, parse_nonnegative(text) == expected) for text, expected in cases])
EXPECTED OUTPUT
[('0', True), ('12', True), (' 7 ', True)]

Each tuple contains one input and its expected result; the local pytest decorator consumes the same shape.

02. Expose a wrong expected value

PYTHON
def parse_nonnegative(text):
    return int(text)
try:
    assert parse_nonnegative('2') == 3
except AssertionError:
    print('case failed: text=2 expected=3')
EXPECTED OUTPUT
case failed: text=2 expected=3

A named pytest case would report this row separately rather than hide it inside a longer loop.

03. Check invalid input behavior in plain Python

PYTHON
def parse_nonnegative(text):
    number = int(text)
    if number < 0:
        raise ValueError('negative number')
    return number
for text in ['-1', 'oops', '']:
    try:
        parse_nonnegative(text)
    except ValueError:
        print(repr(text), 'rejected')
EXPECTED OUTPUT
'-1' rejected
'oops' rejected
'' rejected

The local pytest example expresses each of these as its own pytest.raises case.

Run real pytest cases locally

Save this code as test_parse_numbers.py. Install pytest with python -m pip install pytest, then run python -m pytest -q test_parse_numbers.py. The browser console practices the case table using plain Python; it does not run pytest.

PYTHON
import pytest

def parse_nonnegative(text):
    number = int(text)
    if number < 0:
        raise ValueError('negative number')
    return number

@pytest.mark.parametrize(
    'text, expected',
    [('0', 0), ('12', 12), (' 7 ', 7)],
    ids=['zero', 'two-digits', 'spaces'],
)
def test_valid(text, expected):
    assert parse_nonnegative(text) == expected

@pytest.mark.parametrize(
    'text', ['-1', 'oops', ''],
    ids=['negative', 'letters', 'empty'],
)
def test_invalid(text):
    with pytest.raises(ValueError):
        parse_nonnegative(text)

A closer look

Follow the reasoning, inspect each result, then try the suggested changes in the console below.

01 / 03

Write the cases before the decorator

Parametrization begins with a clear table of inputs and expected outputs. A tuple ('12', 12) means passing the string twelve to the parser should return the integer twelve. Include zero because it is a boundary, a multi-digit value because it is common, and surrounding spaces because int accepts them. Keep the table small enough that each row has a reason to exist.

The browser example runs this table with a plain Python comprehension. That confirms the production function and each expected value without depending on pytest inside the page. When you move to a local test file, use the same rows in @pytest.mark.parametrize('text, expected', cases). The decorator produces one test invocation per row rather than one loop with a single report.

PYTHON
def parse_nonnegative(text):
    number = int(text)
    if number < 0:
        raise ValueError('negative number')
    return number
cases = [('0', 0), ('12', 12), (' 7 ', 7)]
print([(text, parse_nonnegative(text) == expected) for text, expected in cases])
EXPECTED OUTPUT
[('0', True), ('12', True), (' 7 ', True)]
Follow the reasoning
  1. Run the three-case table.
  2. Identify which tuple component is the input and which is expected output.
02 / 03

Make a failing row easy to identify

A test suite earns its value when it finds a mismatch. The deliberately incorrect expectation in this example says text '2' should become 3. The parser returns 2, so the assertion fails. A plain loop would stop at that row. With pytest parametrize, the row is reported as a separate failed case while other rows can still run.

Use ids such as zero, two-digits, and spaces to make results understandable without reading raw tuple representations. IDs describe the scenario; they do not alter test behavior. Keep production code outside the test body, and avoid adding branching logic to the test just to make one row pass. If a row reveals a real contract mismatch, correct the function or expectation.

PYTHON
def parse_nonnegative(text):
    return int(text)
try:
    assert parse_nonnegative('2') == 3
except AssertionError:
    print('case failed: text=2 expected=3')
EXPECTED OUTPUT
case failed: text=2 expected=3
Follow the reasoning
  1. Run the intentionally wrong assertion.
  2. Describe the scenario name you would give this row.
03 / 03

Test invalid inputs as their own cases

A parser also has an error contract. Negative numbers are rejected by our function, while letters and empty text fail at int conversion. All three raise ValueError, but for different reasons. A local pytest file can parametrize these invalid inputs and put the call inside with pytest.raises(ValueError). That creates a separate check for every invalid scenario.

The browser demonstration uses try/except only to make the three outcomes visible without a test runner. Do not write a bare except in production tests, because it can hide unrelated programming errors. The complete local example at the top of this page uses pytest.raises and names each invalid row. Install pytest in a local environment, run python -m pytest -q, and inspect individual case IDs when one fails.

PYTHON
def parse_nonnegative(text):
    number = int(text)
    if number < 0:
        raise ValueError('negative number')
    return number
for text in ['-1', 'oops', '']:
    try:
        parse_nonnegative(text)
    except ValueError:
        print(repr(text), 'rejected')
EXPECTED OUTPUT
'-1' rejected
'oops' rejected
'' rejected
Follow the reasoning
  1. Run the invalid values.
  2. Explain why an unexpected exception type should not be silently accepted.

Try it in Python

Edit the example and run it. Python starts in your browser the first time you click Run.

Python console

Ready to run
Need input()? Add one value per line
Ctrl / ⌘ + Enter to run
OUTPUT
Your output appears here.
Your turnImplement verify_cases(cases) for the browser exercise. Check each (text, expected) row by calling parse_nonnegative and asserting equality; return the number of checked cases. The prepared table should print 3. Then inspect the real @pytest.mark.parametrize example above to see how pytest turns these rows into individual tests.
Need a hint?

Loop over text, expected in cases, assert parse_nonnegative(text) == expected, and return len(cases).

Complete the task and select Check task to verify your code.

    Quick quiz

    Three questions. You can change your answers and try again.

    01.How many pytest cases come from three rows in one parametrize decorator?

    02.Why pass ids to parametrize?

    03.How should a test assert that invalid text raises ValueError?

    Typical mistakes

    Everyone meets these errors. See what causes them and how to fix them.

    Copying nearly identical tests

    COMMON MISTAKE
    test_zero(), test_twelve(), test_spaces() with repeated bodies
    FIX
    @pytest.mark.parametrize('text, expected', rows)

    What happens: Each new input requires another duplicated function.
    Keep one assertion body and enumerate varied inputs as rows.

    Only checking successful values

    COMMON MISTAKE
    rows = [('1', 1), ('2', 2)]
    FIX
    Add separate parametrized invalid cases using pytest.raises(ValueError).

    What happens: Negative and malformed input behavior stays untested.
    The error contract is part of the function's behavior.

    Browse this lesson’s problem fixes

    Sources and further reading