Module 2: Unit Tests with Claude Code

Parametrize: Multiple Scenarios with a Single Test

Parametrize: Multiple Scenarios with a Single Test

Capsule overview

In capsule 03 you learned the AAA pattern and fixtures. You wrote clear tests, one per scenario. But there's a problem that shows up quickly: when a function has many valid cases — for example, a temperature conversion that works for 0°C, 100°C, -40°C, etc. — you end up with 5, 10, or more tests that are identical in structure and only the input values and expected output change. That's repetitive code, hard to maintain and prone to copy-paste errors.

@pytest.mark.parametrize solves that problem: it lets you run a single test with multiple sets of data. Same assert logic, different inputs and outputs. This capsule teaches you when to parametrize, how to do it well, and how to use Claude Code to refactor repetitive tests into cleaner, more maintainable suites.

The skill is crucial when you work with AI: Claude Code tends to generate many similar tests. Knowing when to collapse them with parametrize — and how to ask for it — gives you control over the quality and maintainability of your suite.


The Problem: Repetitive Tests

Five tests that do the same thing

Imagine you have a function to convert Celsius to Fahrenheit:

# temperature.py
def celsius_to_fahrenheit(celsius: float) -> float:
    """Convert Celsius to Fahrenheit."""
    return (celsius * 9/5) + 32

You need to test several values: 0 → 32, 100 → 212, -40 → -40, etc. Without parametrize, you'd write:

# test_temperature.py
import pytest
from temperature import celsius_to_fahrenheit


def test_celsius_0_equals_fahrenheit_32():
    assert celsius_to_fahrenheit(0) == 32


def test_celsius_100_equals_fahrenheit_212():
    assert celsius_to_fahrenheit(100) == 212


def test_celsius_minus_40_equals_minus_40():
    assert celsius_to_fahrenheit(-40) == -40


def test_celsius_37_equals_body_temp():
    assert celsius_to_fahrenheit(37) == 98.6


def test_celsius_minus_273_15_freezing_point():
    assert celsius_to_fahrenheit(-273.15) == pytest.approx(-459.67, rel=1e-3)

Problems with this approach:

  • ❌ 5 functions that repeat the same structure: assert celsius_to_fahrenheit(X) == Y
  • ❌ Adding a new case means copy-paste and the risk of error
  • ❌ If you change the function name, you have to update 5 places
  • ❌ The code doesn't scale: 20 cases = 20 nearly identical functions

The solution: a single test, multiple data sets

With @pytest.mark.parametrize, you collapse it all into a single test:

# test_temperature.py
import pytest
from temperature import celsius_to_fahrenheit


@pytest.mark.parametrize("celsius,expected", [
    (0, 32),
    (100, 212),
    (-40, -40),
    (37, 98.6),
    (-273.15, pytest.approx(-459.67, rel=1e-3)),
])
def test_celsius_to_fahrenheit(celsius, expected):
    assert celsius_to_fahrenheit(celsius) == expected

Advantages:

  • ✅ A single test, 5 runs (pytest counts each row as a case)
  • ✅ Adding a case = adding a tuple to the list
  • ✅ Centralized maintenance: one place for the assert logic
  • ✅ Easy to read: the data table documents the covered cases

When you run pytest -v, you'll see something like:

test_celsius_to_fahrenheit[0-32] PASSED
test_celsius_to_fahrenheit[100-212] PASSED
test_celsius_to_fahrenheit[-40--40] PASSED
test_celsius_to_fahrenheit[37-98.6] PASSED
test_celsius_to_fahrenheit[-273.15-approx] PASSED

Basic Parametrize Syntax

Minimal structure

@pytest.mark.parametrize("arg1,arg2", [
    (value1_a, value2_a),
    (value1_b, value2_b),
])
def test_something(arg1, arg2):
    assert function(arg1) == arg2
  • First argument: a string with the parameter names separated by commas
  • Second argument: a list of tuples; each tuple is a test case
  • Function: receives those parameters and does the assert

Example with a single parameter

@pytest.mark.parametrize("n", [0, 1, 2, -1, 100])
def test_abs_non_negative(n):
    assert abs(n) >= 0

Example with two parameters

@pytest.mark.parametrize("input_val,expected", [
    (0, 32),
    (100, 212),
    (-40, -40),
])
def test_celsius_to_fahrenheit(input_val, expected):
    assert celsius_to_fahrenheit(input_val) == expected

Example with three or more parameters

# math_utils.py
def clamp(value: float, low: float, high: float) -> float:
    """Clamp value between low and high."""
    return max(low, min(high, value))


# test_math_utils.py
@pytest.mark.parametrize("value,low,high,expected", [
    (50, 0, 100, 50),
    (-10, 0, 100, 0),
    (150, 0, 100, 100),
    (0, 0, 100, 0),
    (100, 0, 100, 100),
])
def test_clamp(value, low, high, expected):
    assert clamp(value, low, high) == expected

Multiple Parameters and pytest.param

Using pytest.param for descriptive IDs

By default, pytest generates automatic IDs from the values. With long or complex values, the IDs can be unreadable. pytest.param lets you give explicit names:

@pytest.mark.parametrize("celsius,expected", [
    pytest.param(0, 32, id="freezing_point"),
    pytest.param(100, 212, id="boiling_point"),
    pytest.param(-40, -40, id="celsius_fahrenheit_intersection"),
    pytest.param(37, 98.6, id="body_temperature"),
])
def test_celsius_to_fahrenheit(celsius, expected):
    assert celsius_to_fahrenheit(celsius) == expected

Output with pytest -v:

test_celsius_to_fahrenheit[freezing_point] PASSED
test_celsius_to_fahrenheit[boiling_point] PASSED
test_celsius_to_fahrenheit[celsius_fahrenheit_intersection] PASSED
test_celsius_to_fahrenheit[body_temperature] PASSED

Combining parametrize with marks

You can mark individual cases as xfail, skip, or slow:

# math_utils.py
def square(x: float) -> float:
    return x * x


# test_math_utils.py
@pytest.mark.parametrize("value,expected", [
    (1, 1),
    (2, 4),
    pytest.param(-1, 1, marks=pytest.mark.xfail(reason="negative input not yet supported")),
    pytest.param(0, 0, marks=pytest.mark.skip(reason="edge case, implement later")),
])
def test_square(value, expected):
    assert square(value) == expected
  • pytest.mark.xfail: the test is expected to fail (documents pending behavior)
  • pytest.mark.skip: the test is not run

Several variables with pytest.param

# math_utils.py
def add(a: float, b: float) -> float:
    return a + b


# test_math_utils.py
@pytest.mark.parametrize("a,b,expected", [
    pytest.param(1, 1, 2, id="positive"),
    pytest.param(-1, -1, -2, id="negative"),
    pytest.param(0, 0, 0, id="zeros"),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

When to Parametrize vs When to Write Separate Tests

Practical rule

SituationUseReason
Same logic, different dataParametrizeData-driven: one assert, N sets of data
Different logic or different assertSeparate testsDistinct behaviors require distinct tests
Only input/output changesParametrizeThe ideal case for parametrize
The type of verification changesSeparate testsE.g., one test verifies a return, another verifies an exception

Parametrize: same logic, different data

# ✅ Correct: they all verify "correct conversion"
@pytest.mark.parametrize("celsius,expected", [(0, 32), (100, 212), (-40, -40)])
def test_celsius_to_fahrenheit(celsius, expected):
    assert celsius_to_fahrenheit(celsius) == expected

Separate tests: different behaviors

# ✅ Correct: distinct behaviors
def test_celsius_to_fahrenheit_valid_input():
    assert celsius_to_fahrenheit(0) == 32


def test_celsius_to_fahrenheit_rejects_none():
    with pytest.raises(TypeError):
        celsius_to_fahrenheit(None)


def test_celsius_to_fahrenheit_rejects_string():
    with pytest.raises(TypeError):
        celsius_to_fahrenheit("25")

You wouldn't parametrize the happy path together with the exception cases: the assert is different (assert result vs pytest.raises).

Quick criterion

  • Do only the numbers/strings and the expected result change? → Parametrize
  • Does what you're verifying change (return vs exception vs side effect)? → Separate tests

Claude Code and Parametrize

Claude Code's typical pattern

When you ask "write tests for [function]" with many cases, Claude Code usually generates something like this:

def test_validate_age_18():
    assert validate_age(18) == True

def test_validate_age_21():
    assert validate_age(21) == True

def test_validate_age_0():
    assert validate_age(0) == False

def test_validate_age_negative():
    assert validate_age(-1) == False

def test_validate_age_150():
    assert validate_age(150) == True

def test_validate_age_151():
    assert validate_age(151) == False

Six functions with the same structure. It works, but it's verbose.

How to ask for the refactor

You can ask explicitly:

Refactor these repetitive tests using @pytest.mark.parametrize.
Keep the same coverage but reduce duplication.
Use pytest.param with descriptive ids where it helps read the output.

Or more specifically:

Convert the validate_age tests into a single parametrized test.
Include: 18, 21 (valid), 0, -1 (invalid), 150 (limit), 151 (out of range).
Use ids like "adult_18", "adult_21", "minor_zero", etc.

How the parametrized result looks in pytest -v

test_validate_age[adult_18] PASSED
test_validate_age[adult_21] PASSED
test_validate_age[minor_zero] PASSED
test_validate_age[negative_invalid] PASSED
test_validate_age[max_valid_150] PASSED
test_validate_age[over_max_151] PASSED

Each case appears as an independent test in the report, but the code is a single parametrized test.


Parametrize for Edge Cases and Boundary Testing

Why parametrize fits edge cases

Edge cases are usually "same function, different limit values." Parametrize lets you list them clearly and add more without duplicating logic.

Example: limit and special values

# validators.py
def is_valid_port(port: int) -> bool:
    """Check if port is in valid range 1-65535."""
    if not isinstance(port, int):
        raise TypeError("Port must be integer")
    return 1 <= port <= 65535


# test_validators.py
import sys
import pytest
from validators import is_valid_port


@pytest.mark.parametrize("port,expected", [
    (1, True),
    (65535, True),
    (8080, True),
    (0, False),
    (-1, False),
    (65536, False),
    (sys.maxsize, False),
])
def test_is_valid_port_boundaries(port, expected):
    assert is_valid_port(port) == expected


@pytest.mark.parametrize("invalid_input", [None, "80", 80.0, [], {}])
def test_is_valid_port_rejects_non_integer(invalid_input):
    with pytest.raises(TypeError, match="Port must be integer"):
        is_valid_port(invalid_input)

Notice: the "valid/invalid" cases by range are parametrized in one test; the "wrong type" cases go in another test (a different assert).

Table of typical edge cases

TypeExamples
Zero0
One1
Negative-1
Empty"", [], {}
Lower boundMIN, 0, 1
Upper boundMAX, len-1
Out of rangeMAX+1, -1 for an index
NoneNone
Float vs int1.0 vs 1

Parametrize lets you define one row per case and documents the set of edge cases at a glance.


Comparison: Parametrize vs Separate Tests

Before (separate tests)

# math_utils.py: def add(a, b): return a + b

from math_utils import add


def test_add_positive():
    assert add(1, 1) == 2


def test_add_negative():
    assert add(-1, -1) == -2


def test_add_zero():
    assert add(0, 0) == 0


def test_add_mixed():
    assert add(5, -3) == 2
  • 4 functions
  • The same structure repeated
  • Maintenance in 4 places

After (parametrize)

import pytest
from math_utils import add


@pytest.mark.parametrize("a,b,expected", [
    (1, 1, 2),
    (-1, -1, -2),
    (0, 0, 0),
    (5, -3, 2),
])
def test_add(a, b, expected):
    assert add(a, b) == expected
  • 1 function, 4 runs
  • A single place for the assert
  • Easy to add (100, -50, 50) or other cases

When NOT to parametrize

# ❌ Mixing a normal return with exceptions in the same parametrize
@pytest.mark.parametrize("value,expected", [
    (1, 1),
    (None, "error"),  # An exception assert? Mixes logic
])
def test_something(value, expected):
    ???  # One case does an assert, another pytest.raises. Confusing.

Better: a parametrized test for the happy path and separate tests for each type of error.


Project Connection

In this module's project ("Generated unit test suite") you'll work with a data_utils.py module that includes validations, formatting, and data transformation. Many functions have multiple valid and invalid cases that lend themselves to parametrize:

  • Validators: same assert, different inputs (valid/invalid)
  • Formatters: same assert, different inputs and expected outputs
  • Transformers: same verification logic, different input/output pairs

Using parametrize will let you:

  • ✅ Reduce duplication when Claude Code generates many similar tests
  • ✅ Increase edge case coverage without inflating the number of functions
  • ✅ Keep the suite readable and easy to extend

In capsule 05 you'll learn to validate whether the generated tests (parametrized or not) are actually useful or trivial — the most critical skill of the module.


Troubleshooting

1. "ValueError: Could not resolve parametrize parameter"

Cause: The parameter name in the string doesn't match the function's argument name.

# ❌ Incorrect
@pytest.mark.parametrize("x,y", [(1, 2)])
def test_foo(a, b):  # a, b don't exist in parametrize
    assert a + b == 3

# ✅ Correct
@pytest.mark.parametrize("a,b", [(1, 2)])
def test_foo(a, b):
    assert a + b == 3

Solution: The names in "a,b" must match the function arguments exactly.


2. Unreadable IDs with complex values

Cause: pytest generates automatic IDs from the repr() of the values. With lists, dicts, or objects, the ID can be very long.

# Ugly IDs: [1-2-3-4-5], [0--1]
@pytest.mark.parametrize("values,expected", [
    ([1, 2, 3, 4, 5], 15),
    ([0, -1], -1),
])

Solution: Use pytest.param(..., id="descriptive_name"):

@pytest.mark.parametrize("values,expected", [
    pytest.param([1, 2, 3, 4, 5], 15, id="positive_sum"),
    pytest.param([0, -1], -1, id="with_negative"),
])

3. Parametrize with fixtures: evaluation order

Cause: If you use parametrize and fixtures together, pytest evaluates parametrize first. If the fixture depends on the parameter, there can be confusion.

@pytest.fixture
def data(input_val):  # input_val comes from parametrize
    return process(input_val)

@pytest.mark.parametrize("input_val", [1, 2, 3])
def test_something(data):  # data uses input_val
    assert data > 0

Solution: Fixtures can receive parametrize parameters as arguments. Make sure the fixture declares input_val and that the test uses data (or whatever you need). If something fails, check that there are no circular dependencies between fixtures and parameters.


4. Mixing assert types in a single parametrize

Cause: One case verifies a return, another verifies an exception. The test logic gets complicated.

# ❌ Confusing
@pytest.mark.parametrize("value,expected", [
    (1, 1),
    (None, None),  # Or should it raise? How do you assert it?
])
def test_parse(value, expected):
    ???

Solution: Split into two tests: one parametrized for the happy path and another(s) for exceptions:

@pytest.mark.parametrize("value,expected", [(1, 1), (2, 2)])
def test_parse_valid(value, expected):
    assert parse(value) == expected

def test_parse_none_raises():
    with pytest.raises(ValueError):
        parse(None)

5. Different behavior in one parameter makes the test fragile

Cause: You parametrize cases that actually have different rules (e.g., positives vs negatives with different logic).

Solution: If a subset of cases has a different rule, split it into another parametrized test or into separate tests. Parametrize for "same rule, different data," not for mixing behaviors.


Exercises

Exercise 1: Basic parametrize

The function is_even(n) returns True if n is even, False if it's odd. Write a parametrized test that covers: 0, 2, 4, -2 (even) and 1, 3, -1 (odd).

See solution
# math_utils.py
def is_even(n: int) -> bool:
    return n % 2 == 0


# test_math_utils.py
import pytest
from math_utils import is_even


@pytest.mark.parametrize("n,expected", [
    (0, True),
    (2, True),
    (4, True),
    (-2, True),
    (1, False),
    (3, False),
    (-1, False),
])
def test_is_even(n, expected):
    assert is_even(n) == expected

Exercise 2: pytest.param with IDs

Refactor the previous test using pytest.param with descriptive IDs like "zero", "positive_even", "negative_odd".

See solution
@pytest.mark.parametrize("n,expected", [
    pytest.param(0, True, id="zero"),
    pytest.param(2, True, id="positive_even"),
    pytest.param(4, True, id="positive_even_large"),
    pytest.param(-2, True, id="negative_even"),
    pytest.param(1, False, id="positive_odd"),
    pytest.param(3, False, id="positive_odd_large"),
    pytest.param(-1, False, id="negative_odd"),
])
def test_is_even(n, expected):
    assert is_even(n) == expected

Exercise 3: When to parametrize

You have a function divide(a, b) that returns a / b. Would you parametrize these cases in a single test?

  • (10, 2) → 5
  • (0, 5) → 0
  • (5, 0) → raises ZeroDivisionError

Justify it and write the appropriate tests.

See solution

Not all in the same parametrize. The first two share an assert (assert divide(a, b) == expected). The third verifies an exception (a different assert).

Solution:

# math_utils.py
def divide(a: float, b: float) -> float:
    return a / b


# test_math_utils.py
@pytest.mark.parametrize("a,b,expected", [
    (10, 2, 5),
    (0, 5, 0),
])
def test_divide_valid(a, b, expected):
    assert divide(a, b) == expected


def test_divide_by_zero_raises():
    with pytest.raises(ZeroDivisionError):
        divide(5, 0)

Exercise 4: Edge cases with parametrize

The function safe_int(s: str) converts a string to int; it returns None if it fails. Write a parametrized test that covers: "42", "0", "-1", " 10 " (with spaces), "", "abc", None.

See solution

None and the invalid strings may require a different assert (e.g., that it returns None). If safe_int returns None for all the invalid ones, you can parametrize everything:

# validators.py
def safe_int(s) -> int | None:
    if s is None:
        return None
    try:
        return int(str(s).strip())
    except (ValueError, TypeError):
        return None


# test_validators.py
import pytest
from validators import safe_int


@pytest.mark.parametrize("s,expected", [
    ("42", 42),
    ("0", 0),
    ("-1", -1),
    (" 10 ", 10),
])
def test_safe_int_valid(s, expected):
    assert safe_int(s) == expected


@pytest.mark.parametrize("s", ["", "abc", None, "12.34"])
def test_safe_int_invalid_returns_none(s):
    assert safe_int(s) is None

If safe_int(None) raised an exception instead of returning None, that case would go in a separate test with pytest.raises.


Exercise 5: Refactor with Claude Code

Copy these 6 tests into a file and ask Claude Code to refactor them with parametrize:

def test_normalize_whitespace_single_space():
    assert normalize_whitespace("hello   world") == "hello world"

def test_normalize_whitespace_tabs():
    assert normalize_whitespace("hello\t\tworld") == "hello world"

def test_normalize_whitespace_empty():
    assert normalize_whitespace("") == ""

def test_normalize_whitespace_only_spaces():
    assert normalize_whitespace("   ") == ""

def test_normalize_whitespace_no_change():
    assert normalize_whitespace("hello world") == "hello world"

def test_normalize_whitespace_leading_trailing():
    assert normalize_whitespace("  hello  ") == "hello"

Compare the result with the suggested solution below.

See solution
# text_utils.py
def normalize_whitespace(s: str) -> str:
    """Collapse multiple whitespace to single space, strip edges."""
    if not s:
        return ""
    return " ".join(s.split())


# test_text_utils.py
import pytest
from text_utils import normalize_whitespace


@pytest.mark.parametrize("input_val,expected", [
    ("hello   world", "hello world"),
    ("hello\t\tworld", "hello world"),
    ("", ""),
    ("   ", ""),
    ("hello world", "hello world"),
    ("  hello  ", "hello"),
])
def test_normalize_whitespace(input_val, expected):
    assert normalize_whitespace(input_val) == expected

With optional IDs:

@pytest.mark.parametrize("input_val,expected", [
    pytest.param("hello   world", "hello world", id="multiple_spaces"),
    pytest.param("hello\t\tworld", "hello world", id="tabs"),
    pytest.param("", "", id="empty"),
    pytest.param("   ", "", id="only_spaces"),
    pytest.param("hello world", "hello world", id="no_change"),
    pytest.param("  hello  ", "hello", id="leading_trailing"),
])
def test_normalize_whitespace(input_val, expected):
    assert normalize_whitespace(input_val) == expected

Exercise 6: Three parameters and boundary testing

The function in_range(value, low, high) returns True if low <= value <= high. Write a parametrized test with three parameters that covers: value inside, value equal to low, value equal to high, value below low, value above high.

See solution
# range_utils.py
def in_range(value: float, low: float, high: float) -> bool:
    return low <= value <= high


# test_range_utils.py
import pytest
from range_utils import in_range


@pytest.mark.parametrize("value,low,high,expected", [
    (50, 0, 100, True),
    (0, 0, 100, True),
    (100, 0, 100, True),
    (-1, 0, 100, False),
    (101, 0, 100, False),
])
def test_in_range(value, low, high, expected):
    assert in_range(value, low, high) == expected

Summary

  • ✅ @pytest.mark.parametrize runs a single test with multiple sets of data
  • ✅ Use parametrize when the logic is the same and only input/output changes
  • ✅ Use separate tests when the type of verification changes (return vs exception)
  • ✅ pytest.param(..., id="name") improves the readability of the IDs in pytest -v
  • ✅ You can combine parametrize with marks (xfail, skip) for special cases
  • ✅ Claude Code tends to generate repetitive tests; you can ask it to refactor with parametrize
  • ✅ Parametrize is very useful for edge cases and boundary testing
  • ✅ The rule: if only the data changes → parametrize; if the logic or assert changes → separate tests

Additional Resources

  1. pytest: Parametrize — Official documentation — Complete syntax and usage reference
  2. pytest.param — Documentation — Using pytest.param for IDs and marks
  3. Data-driven testing with pytest (Real Python) — Practical examples and best practices
  4. Parametrize fixtures (pytest docs) — Combining parametrize with fixtures
  5. Test design: Equivalence partitioning and boundary value analysis — Fundamentals of test design for edge cases
  6. Python Testing with pytest (Pragmatic Programmers) — A reference book on pytest and parametrize

Module 2, Capsule 04 — Testing with Claude Code Guide One test, many data sets: parametrize for coverage without duplication