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
| Situation | Use | Reason |
|---|---|---|
| Same logic, different data | Parametrize | Data-driven: one assert, N sets of data |
| Different logic or different assert | Separate tests | Distinct behaviors require distinct tests |
| Only input/output changes | Parametrize | The ideal case for parametrize |
| The type of verification changes | Separate tests | E.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
| Type | Examples |
|---|---|
| Zero | 0 |
| One | 1 |
| Negative | -1 |
| Empty | "", [], {} |
| Lower bound | MIN, 0, 1 |
| Upper bound | MAX, len-1 |
| Out of range | MAX+1, -1 for an index |
| None | None |
| Float vs int | 1.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.parametrizeruns 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 inpytest -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
- pytest: Parametrize — Official documentation — Complete syntax and usage reference
- pytest.param — Documentation — Using
pytest.paramfor IDs and marks - Data-driven testing with pytest (Real Python) — Practical examples and best practices
- Parametrize fixtures (pytest docs) — Combining parametrize with fixtures
- Test design: Equivalence partitioning and boundary value analysis — Fundamentals of test design for edge cases
- 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