Python Testing #3 parametrize and Markers: Multiply Cases, Run Selectively
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 #
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) == 0The 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 #
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) == expectedThe 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.
@pytest.mark.parametrize("role", ["admin", "member"])
@pytest.mark.parametrize("active", [True, False])
def test_permission(role, active):
assert can_login(role, active) == active2×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.
@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.
@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| Marker | Meaning |
|---|---|
skip | Not running this right now; the reason goes in reason |
skipif | Skip only when a condition is true (OS, Python version, etc.) |
xfail | We 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.
[tool.pytest.ini_options]
markers = [
"slow: long-running tests",
"integration: tests that need external services",
]@pytest.mark.slow
def test_full_report():
...At run time, you select with the -m and -k options.
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.slowto 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 tableids: 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
slowmarker: 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.