Skip to content

How to Parameterize Tests with pytest

When a test function contains the right logic but needs to run against multiple inputs, copying the function and changing the values creates maintenance drag and hides which cases actually exist. @pytest.mark.parametrize solves this by generating a separate test case for each set of inputs from a single function definition.

Prerequisites

A uv-managed project with pytest as a dev dependency. If you haven’t set that up yet, follow Setting up testing with pytest and uv.

Parametrize a Single Argument

Pass the parameter name as a string and the values as a list:

tests/test_math.py
import pytest

@pytest.mark.parametrize("n", [0, 1, 4, 9, 16])
def test_sqrt_of_perfect_square(n):
    result = n**0.5
    assert result == int(result)
uv run pytest tests/test_math.py -v

pytest runs test_sqrt_of_perfect_square five times, once per value of n. The -v flag shows each case on its own line with the parameter value in brackets.

Parametrize Multiple Arguments

Separate parameter names with a comma and pass tuples of values:

tests/test_math.py
import pytest

@pytest.mark.parametrize("base,exponent,expected", [
    (2, 0, 1),
    (2, 3, 8),
    (10, 2, 100),
    (5, 3, 125),
])
def test_power(base, exponent, expected):
    assert base**exponent == expected

Each tuple maps positionally to the parameter names. A mismatch in length raises a collection error before any test runs.

Add Custom Test IDs

By default, pytest builds test IDs from the parameter values. When those values are opaque (long strings, complex objects), custom IDs make output readable and let you target specific cases with -k.

Pass an ids list with one label per parameter set:

tests/test_slugify.py
import pytest

def slugify(text):
    return text.lower().strip().replace(" ", "-")

@pytest.mark.parametrize(
    "text,expected",
    [
        ("Hello World", "hello-world"),
        ("  Leading Spaces", "leading-spaces"),
        ("ALLCAPS", "allcaps"),
    ],
    ids=["lowercase-and-hyphen", "leading-spaces", "allcaps"],
)
def test_slugify(text, expected):
    assert slugify(text) == expected

For inline labeling, use pytest.param with the id keyword:

tests/test_slugify.py
import pytest

@pytest.mark.parametrize("text,expected", [
    pytest.param("Hello World", "hello-world", id="lowercase-and-hyphen"),
    pytest.param("  Leading Spaces", "leading-spaces", id="leading-spaces"),
    pytest.param("ALLCAPS", "allcaps", id="allcaps"),
])
def test_slugify(text, expected):
    assert slugify(text) == expected

Both approaches produce the same output:

$ uv run pytest tests/test_slugify.py -v
tests/test_slugify.py::test_slugify[lowercase-and-hyphen] PASSED
tests/test_slugify.py::test_slugify[leading-spaces] PASSED
tests/test_slugify.py::test_slugify[allcaps] PASSED

Run a single case by name:

uv run pytest -k "allcaps"

Generate All Combinations with Stacked Decorators

Stacking two @pytest.mark.parametrize decorators produces the cartesian product of their values:

tests/test_format.py
import pytest

@pytest.mark.parametrize("precision", [1, 2, 4])
@pytest.mark.parametrize("value", [0.1, 3.14159, 100.0])
def test_round_format(value, precision):
    result = round(value, precision)
    assert isinstance(result, float)

This generates 9 test cases (3 values x 3 precisions). Use this when two parameters are independent and every combination is a valid case.

Mark Expected Failures in a Parameter Set

Use pytest.param with the marks keyword to flag cases that should fail:

tests/test_math.py
import pytest

@pytest.mark.parametrize("test_input,expected", [
    ("3+5", 8),
    ("2+4", 6),
    pytest.param("6*9", 42, marks=pytest.mark.xfail, id="hitchhiker"),
])
def test_eval(test_input, expected):
    assert eval(test_input) == expected

The hitchhiker case is reported as xfail instead of FAILED, keeping the rest of the suite green while documenting the known discrepancy.

Parametrize a Fixture with indirect

When parameter values need transformation or setup before the test runs, indirect=True routes values through a fixture instead of injecting them directly:

tests/test_api.py
import pytest

@pytest.fixture
def db_connection(request):
    engine = request.param
    conn = {"engine": engine, "connected": True}
    yield conn
    conn["connected"] = False

@pytest.mark.parametrize("db_connection", ["sqlite", "postgres"], indirect=True)
def test_query(db_connection):
    assert db_connection["connected"]
    assert db_connection["engine"] in ("sqlite", "postgres")

pytest passes "sqlite" and "postgres" to the db_connection fixture via request.param. The fixture builds the connection object and yields it to the test. This keeps setup logic in fixtures where it belongs, and each parameter value still generates a separate test case.

To apply indirect to only some parameters, pass a list of names:

@pytest.mark.parametrize("db_connection,query", [("sqlite", "SELECT 1")], indirect=["db_connection"])
def test_run_query(db_connection, query):
    assert db_connection["connected"]
    assert query == "SELECT 1"

Here db_connection goes through the fixture; query is injected as a plain string.

Learn More

Last updated on