Python Testing #3 parametrize and Markers: Multiply Cases, Run Selectively

5 min read

We ran our first test in #1 Getting started with pytest and cleaned up setup code in #2 Fixtures. This time we’ll tackle repetition in the test body itself. If you’ve ever copy-pasted nearly identical tests just to verify the same function with different inputs, @pytest.mark.parametrize eliminates exactly that. In the second half, we’ll cover markers — labels you attach to tests so you can run only the ones you want.

If you’re copying the same test three times #

🚫 Three tests that differ only in input
def test_add_positive():
    assert add(1, 2) == 3

def test_add_negative():
    assert add(-1, -2) == -3

def test_add_zero():
    assert add(0, 0) == 0

The logic is one line everywhere: assert add(a, b) == expected. Only the numbers differ, yet there are three functions. Every new case means inventing another function name and copying the body, and if the verification logic changes, you fix it in three places.

@pytest.mark.parametrize: the case table is the spec #

✅ Cases as a table
import pytest
from calculator import add

@pytest.mark.parametrize("a, b, expected", [
    (1, 2, 3),
    (-1, -2, -3),
    (0, 0, 0),
])
def test_add(a, b, expected):
    assert add(a, b) == expected

The first argument is the parameter names; the second is the list of cases. pytest runs the test once per case, so there’s one function but pytest -v reports three: test_add[1-2-3], test_add[-1--2--3], and test_add[0-0-0]. The case list itself acts as a spec — “given these inputs, this function produces these results” — and adding a new case means adding one tuple.

Auto-generated IDs like [-1--2--3] are hard to read. Append ids=["positive", "negative", "zero"] after the case list and results show up as test_add[negative], so when something fails you immediately see which case broke. The more cases you have, the more this pays off.

Stacking parametrize: automatic combinations #

Stack the decorator twice and you get every combination.

Generating combinations
@pytest.mark.parametrize("role", ["admin", "member"])
@pytest.mark.parametrize("active", [True, False])
def test_permission(role, active):
    assert can_login(role, active) == active

2×2 yields four cases automatically. Be aware, though, that case counts multiply as parameters grow — it’s worth asking whether you genuinely need every combination before reaching for this.

Error cases as a table too: combining with pytest.raises #

Boundary values and invalid inputs are where parametrize shines brightest.

A collection of error cases
@pytest.mark.parametrize("bad_input", ["", "abc", "-1", "200"])
def test_parse_age_rejects(bad_input):
    with pytest.raises(ValueError):
        parse_age(bad_input)

Put the happy-path table and the error-case table side by side, and the test file alone reveals what the function accepts and what it rejects.

skip, skipif, xfail: what each marker means #

A marker is a label you attach to a test. Let’s start by distinguishing the three built into pytest.

The three built-in markers
@pytest.mark.skip(reason="payment module is being replaced")
def test_legacy_payment():
    ...

@pytest.mark.skipif(sys.platform == "win32", reason="Unix-only feature")
def test_unix_socket():
    ...

@pytest.mark.xfail(reason="issue #142: negative amount bug", strict=True)
def test_negative_amount():
    assert charge(-1000) is None
MarkerMeaning
skipNot running this right now; the reason goes in reason
skipifSkip only when a condition is true (OS, Python version, etc.)
xfailWe know this fails — documentation of a known bug

xfail is especially useful. When you’ve found a bug but can’t fix it right away, leaving the test marked xfail instead of deleting it keeps the bug on record. With strict=True, the moment the bug gets fixed and the test passes, you get an XPASS failure — so you never miss the right time to remove the marker.

Custom markers: register them, then select with -m #

You can define markers yourself. Register them in pyproject.toml first — unregistered markers produce a warning, and an error if the --strict-markers option is on.

pyproject.toml
[tool.pytest.ini_options]
markers = [
    "slow: long-running tests",
    "integration: tests that need external services",
]
Attaching a marker
@pytest.mark.slow
def test_full_report():
    ...

At run time, you select with the -m and -k options.

Selective runs
pytest -m slow                        # only the slow marker
pytest -m "not slow"                  # everything except slow
pytest -m "integration and not slow"  # and, or, not combinations work
pytest -k parse                       # only tests with "parse" in the name

-m selects by marker, -k by test name. -k works even on tests without any marker, and supports combinations like pytest -k "parse and not rejects".

The slow-marker workflow #

The most common use of a custom marker is separating out slow tests. Once the full suite gets slow, people stop running tests during development — and from that moment, the tests lose their value.

  • Attach @pytest.mark.slow to any test taking longer than a second
  • During development, run fast with pytest -m "not slow"
  • Before committing, and in CI, run everything with pytest

That one distinction gives you two tiers: a “fast suite that runs often” and a “full suite that runs occasionally.” We’ll finish the CI side of this in part 7.

Recap #

  • @pytest.mark.parametrize: collapses input-only test variations into one case table
  • ids: gives cases readable names
  • Stacked decorators: generate parameter combinations automatically
  • Combined with pytest.raises: error cases and boundary values become a table-form spec too
  • skip/skipif/xfail: skipping, conditional skipping, and documenting known bugs
  • Custom markers + -m, name-based -k: run only the tests you choose
  • The slow marker: operate a fast suite and a full suite as separate tiers

In the next post (#4 mock and monkeypatch), we’ll cover the tools for testing code that depends on the outside — how to replace the things a test can’t control, like time, environment variables, and network calls, with fakes.

X