parametrize: one test, many inputs#

← Back to Real pytest

The problem this solves#

Without parametrize, testing shipping_cost across its three tiers and two boundary values looks like five separate, nearly identical functions:

def test_shipping_cost_half_kg():
    assert shipping_cost(0.5) == 5

def test_shipping_cost_one_kg():
    assert shipping_cost(1) == 5

def test_shipping_cost_two_kg():
    assert shipping_cost(2) == 10

# ... and so on

Every one of those is the same test, structurally: call the function, assert the result. The only thing that changes is a pair of numbers. That's real duplication, and it comes with the same risk any copy-pasted code does: someone adds a sixth tier to shipping_cost later, updates the first test they see to match, and never notices the other four are now testing an outdated understanding of the function, because there was never one place holding all five cases together.

The syntax, precisely#

@pytest.mark.parametrize("weight_kg, expected_cost", [...]) takes two things: a string naming the parameter (or parameters, comma-separated, matching the tuple shape below) the test function should receive, and a list of values, one entry per test case. When there's more than one parameter name, each entry in the list is a tuple, positionally matched to the names in the string: (0.5, 5) means weight_kg = 0.5, expected_cost = 5 for that one run. pytest then calls the decorated test function once per entry, passing that entry's values in as real arguments, exactly as if you'd called the function directly with them.

Notice the test function itself, test_shipping_cost(weight_kg, expected_cost), is not aware it's being run five times. It's an entirely ordinary test that happens to receive its inputs as parameters instead of hardcoding them; parametrize is what turns "call this once" into "call this once per case," from the outside.

How failures get reported#

Run the snippet above with pytest -v and you'll see each parametrized case listed as its own test, with the actual input values baked into its name, something like test_shipping_cost[0.5-5], test_shipping_cost[1-5], and so on. That's deliberate: if the 2.0 case ever starts failing, the output tells you specifically that test_shipping_cost[2-10] failed, not just that some unspecified run of test_shipping_cost did. You get the readability of five separate, individually reportable tests, from one function and one list of inputs.

pytest.raises: asserting that something goes wrong, on purpose#

Not every test is checking a normal return value. Sometimes the correct behavior is raising an exception, an invalid input should be rejected, not silently accepted. with pytest.raises(SomeException): wraps a block of code and asserts that running it raises exactly that exception; if the block runs to completion without raising, or raises a different exception, the test fails. Pairing this with parametrize, as in test_shipping_cost_rejects_negative_weight above, lets you check that several different bad inputs all get rejected the same way, with the same "one test, many cases" structure as the success-path version.

What to watch for#

  • Mismatching the parameter-name string and the shape of each tuple. "weight_kg, expected_cost" with two names needs two-element tuples; a single name ("weight_kg") needs plain values, not one-element tuples.
  • Writing a huge, unrelated grab-bag of cases under one parametrize call. If the cases don't share the exact same assertion logic, they usually don't belong in the same parametrized test; that's a sign you have two different things to test, not one test with more inputs.
  • Forgetting that each parametrized case still has to pass on its own. parametrize doesn't average results or accept "most of them passed"; every single case is its own test, and every one of them has to pass for the suite to be green.

TL;DR#

This page covers @pytest.mark.parametrize, the tool for running the same test logic against several inputs without writing (or copy-pasting) a separate test function for each one.

  • The problem it solves: several near-identical tests that differ only in the numbers or values plugged in, and the risk that comes with copy-pasting a test, that one copy gets updated later and the others quietly don't.
  • @pytest.mark.parametrize("param_name", [value1, value2, ...]) runs the decorated test once per value in the list, each time with param_name bound to that value, as if it were an ordinary parameter.
  • pytest reports each case as its own separate, individually named test in its output, so a failure tells you exactly which input failed, not just that "the test" failed somewhere among several cases.
  • pytest.raises(...) is the general pytest way to assert that a block of code raises a specific exception; it pairs naturally with parametrize when several different bad inputs should all raise the same kind of error.
import pytest


def shipping_cost(weight_kg):
    if weight_kg <= 1:
        return 5
    if weight_kg <= 5:
        return 10
    return 20


@pytest.mark.parametrize("weight_kg, expected_cost", [
    (0.5, 5),
    (1, 5),
    (2, 10),
    (5, 10),
    (5.5, 20),
])
def test_shipping_cost(weight_kg, expected_cost):   # runs five times, once per tuple above
    assert shipping_cost(weight_kg) == expected_cost


@pytest.mark.parametrize("bad_weight", [-1, -0.5])
def test_shipping_cost_rejects_negative_weight(bad_weight):
    with pytest.raises(ValueError):   # pytest's general way to assert an exception gets raised
        shipping_cost(bad_weight)     # (shipping_cost would need to raise ValueError for this to pass)