Reducing Test Duplication with pytest.parametrize: A Practical Guide
Learn how to replace duplicated test functions with a single parametrized test in pytest, combine it with fixtures, use marks and custom IDs, and understand the trade‑offs.
01 Oct 2025, 15:20 UTC

Problem: Repeating the Same Test Logic
When you need to verify a function across many input combinations, it is tempting to copy‑paste a test function and change the arguments each time. This leads to a large test file where a single logic change requires edits in many places, increasing the chance of inconsistency and making the suite harder to maintain.
Thesis: Use @pytest.mark.parametrize to Generate Test Cases Declaratively
The @pytest.mark.parametrize decorator lets you define a single test function and supply a table of argument sets. Pytest expands each set into a distinct test item, which appears separately in reports, runs independently, and can be filtered or marked like any other test.
Basic Parametrization
Consider a simple discount calculator:
# discount.py
def calculate_discount(price: float, rate: float) -> float:
"""Return price after applying a percentage discount."""
if rate < 0 or rate > 100:
raise ValueError("rate must be between 0 and 100")
return price * (1 - rate / 100)
Instead of writing separate tests for each price/rate pair, you can parametrize:
# test_discount.py
import pytest
from discount import calculate_discount
@pytest.mark.parametrize(
"price,rate,expected",
[
(100, 0, 100), # no discount
(100, 25, 75), # 25% off
(100, 50, 50), # half price
(100, 100, 0), # free
(50, 10, 45), # different base
],
)
def test_calculate_discount(price, rate, expected):
assert calculate_discount(price, rate) == expected
When you run pytest -v, each tuple produces a separate test line, e.g.:
test_discount.py::test_calculate_discount[100-0-100] PASSED ...
Combining with Fixtures (indirect)
Sometimes the parameter values need to drive fixture setup rather than being passed directly to the test function. The indirect flag tells pytest to treat the parameter as a fixture name.
Suppose you want to test the discount function with different currency objects that affect rounding:
# conftest.py
import pytest
class Currency:
def __init__(self, name: str, precision: int):
self.name = name
self.precision = precision
def round(self, amount: float) -> float:
return round(amount, self.precision)
@pytest.fixture
def currency(request):
# request.param will be a tuple (name, precision)
name, precision = request.param
return Currency(name, precision)
The test uses indirect parametrization:
# test_discount_currency.py
import pytest
from discount import calculate_discount
@pytest.mark.parametrize(
"currency,price,rate,expected",
[
(("USD", 2), 100, 12.5, 87.5),
(("JPY", 0), 1000, 10, 900),
(("BTC", 8), 0.01, 50, 0.005),
],
indirect=["currency"], # tells pytest to feed the first value to the currency fixture
)
def test_calculate_discount_with_currency(currency, price, rate, expected):
raw = calculate_discount(price, rate)
assert currency.round(raw) == expected
Here the currency fixture receives each tuple, creates the appropriate object, and the test uses it for rounding verification.
Selective Execution with Marks and Readable IDs
You can combine parametrize with pytest marks to skip or tag specific cases without duplicating code.
# test_discount_marks.py
import pytest
from discount import calculate_discount
@pytest.mark.parametrize(
"price,rate,expected",
[
(100, 0, 100),
pytest.param(100, 25, 75, marks=pytest.mark.slow),
(100, 50, 50),
pytest.param(100, 100, 0, marks=pytest.mark.xfail(reason="free item policy under review")),
(50, 10, 45),
],
ids=["zero", "quarter", "half", "full", "fifty"],
)
def test_calculate_discount(price, rate, expected):
assert calculate_discount(price, rate) == expected
The ids list supplies custom identifiers that replace the auto‑generated indices, making failure output clearer: test_discount_marks.py::test_calculate_discount[quarter]. Marks like slow or xfail can then be used with pytest -m "not slow" to skip those cases.
Worked Example: End‑to‑End Verification
To see the feature in action, follow these steps in a fresh virtual environment:
pip install pytest- Create the three files above (
discount.py,conftest.py,test_discount_currency.py) in a directory. - Run
pytest -v. You should see six test lines (two parametrized sets × three currency fixtures). - Introduce a deliberate fault, e.g., change the expected value in one parametrized tuple to
999. - Run
pytest -vagain; the failure line will show the specific ID, e.g.,test_discount_currency.py::test_calculate_discount[USD-2-100-12.5-87.5], confirming isolation. - Use
pytest --collect-onlyto list all collected items and verify the count matcheslen(param_sets) * len(fixture_variants).
These commands require only read access to the project directory; no special privileges are needed.
Trade‑offs and Limitations
- Combinatorial explosion: Adding many parameters multiplies test cases. A Cartesian product of five values each for three arguments yields 125 tests, which can lengthen CI runs. Mitigate by narrowing the parameter space or using
pytest.mark.skipfor irrelevant combos. - Indirect complexity: When using
indirect, the fixture signature becomes less obvious to newcomers. Document the fixture’s purpose and keep the parameter structure simple (e.g., tuples or dictionaries). - Non‑hashable values: Lists or custom objects as parameters prevent pytest from auto‑generating IDs; you must supply an
idscallable or list to avoid cryptic names liketest[0].
Actionable Closing
Start by identifying a test file with repetitive test functions that differ only in input values. Replace them with a single parametrized test, verify the expansion with pytest --collect-only, and add custom ids for clarity. Monitor test runtime; if it grows beyond acceptable limits, consider splitting the parameter space or using marks to exclude expensive cases in regular runs. This approach keeps your test suite DRY, easier to update, and provides granular reporting without sacrificing readability.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.