backend / testing / pytest / 04_parametrize_patterns.md

Parametrize patterns

3 min read source

Parametrize patterns

@pytest.mark.parametrize runs the same test function with multiple inputs. Each input becomes a separately reported test.

Basic shape

@pytest.mark.parametrize("n,expected", [
    (0, 0),
    (1, 1),
    (5, 25),
])
def test_square(n, expected):
    assert n * n == expected
  • First arg: comma-separated parameter names (or a list of strings).
  • Second arg: list of value tuples (one per case).

Pytest generates three tests with IDs like test_square[0-0], test_square[1-1], test_square[5-25].

Stacked parametrize — cartesian product

@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", ["a", "b"])
def test_combo(x, y):
    ...

Runs four tests: (1, "a"), (1, "b"), (2, "a"), (2, "b"). Outer decorator iterates faster (closer to the function).

pytest.param — per-case marks and IDs

@pytest.mark.parametrize("input,expected", [
    pytest.param(0, 1, id="zero"),
    pytest.param(1, 2, id="one"),
    pytest.param(-1, 0, id="negative", marks=pytest.mark.xfail),
    pytest.param(None, None, id="none", marks=pytest.mark.skip(reason="not supported yet")),
])
def test_increment(input, expected):
    assert increment(input) == expected

pytest.param(...):

  • id="..." — readable name in output (test_increment[zero] instead of test_increment[0-1]).
  • marks=pytest.mark.xfail — expected failure for this case only.
  • marks=pytest.mark.skip(reason="...") — skip this case only.

You can stack marks: marks=[pytest.mark.slow, pytest.mark.xfail].

ids — readable test output

@pytest.mark.parametrize(
    "user",
    [User("alice", admin=True), User("bob", admin=False)],
    ids=["admin", "regular"],
)
def test_permission(user):
    ...

Without ids, pytest tries to render objects — test_permission[user0], test_permission[user1] is what you get for non-primitive values.

ids can also be a callable:

def make_id(val):
    return f"u_{val.name}_admin{val.admin}"

@pytest.mark.parametrize("user", users, ids=make_id)
def test_permission(user): ...

Parametrizing fixtures

@pytest.fixture(params=["sqlite", "postgres"])
def db(request):
    return connect(request.param)

def test_query(db):
    assert db.execute("SELECT 1").scalar() == 1

Every test that uses db runs twice. Combine with pytest.mark.parametrize and you get the full cartesian product:

@pytest.mark.parametrize("query", ["SELECT 1", "SELECT 2"])
def test_query(db, query):
    # runs 4 times: 2 backends × 2 queries
    ...

See 03_fixtures_deep_dive.md for params and indirect.

Indirect — through the fixture

@pytest.fixture
def db(request):
    backend = request.param
    db = connect(backend)
    yield db
    db.close()

@pytest.mark.parametrize("db", ["sqlite", "postgres"], indirect=True)
def test_query(db):
    ...

Same effect as a parametrized fixture, but parametrization lives next to the test rather than in a fixture. Use this when only some tests need the param.

indirect=["db"] to mark only some args as indirect when there are multiple.

Loading test data from a file

import json
from pathlib import Path

cases = json.loads(Path("tests/cases.json").read_text())

@pytest.mark.parametrize(
    "case",
    cases,
    ids=[c["name"] for c in cases],
)
def test_endpoint(case):
    response = call(case["input"])
    assert response == case["expected"]

Useful for golden-file / table-driven tests.

Generating cases programmatically

def pytest_generate_tests(metafunc):
    if "code" in metafunc.fixturenames:
        codes = list(Path("snippets").glob("*.py"))
        metafunc.parametrize("code", codes, ids=[c.stem for c in codes])

pytest_generate_tests is a hook in conftest.py. Use when parameter values aren’t known until collection time (file discovery, env-dependent).

Conditional parametrize

import sys

cases = [
    pytest.param("a", id="ascii"),
    pytest.param("ä", id="unicode", marks=pytest.mark.skipif(sys.platform == "win32", reason="not supported")),
]

@pytest.mark.parametrize("text", cases)
def test_text(text): ...

Common pitfalls

  • Mutable values shared across cases: each case gets the same dict if you do [{"k": []}, {"k": []}] but mutate it — use deepcopy or factory.
  • Empty parametrize list: @pytest.mark.parametrize("x", []) skips silently. Pytest 7+ warns; older silently passes. Use pytest.skip explicitly if intentional.
  • IDs collide: pytest dedupes by appending 0, 1, … to repeated IDs. Hard to read — give explicit unique IDs.
  • Type coercion in IDs: pytest.param(1, "1") produces [1-1] — both indistinguishable. Use ids to disambiguate.

Interview angle

  • Q: “How do you run the same test with multiple inputs?” — @pytest.mark.parametrize.
  • Q: “How do you mark a single parametrize case as expected to fail?” — pytest.param(..., marks=pytest.mark.xfail).
  • Follow-up: “Difference between parametrizing a fixture and using indirect=True?” — fixture-level vs test-level location of the parameter list. Indirect lets you transform values via fixture logic.
  • Follow-up: “How do you generate cases at collection time?” — pytest_generate_tests hook.

See 03_fixtures_deep_dive.md, 02_pytest_concepts.md.