Parametrize and Markers
James counts his categorize_by_count tests. Six functions, all following the same AAA pattern:
def test_categorize_five_returns_short() -> None:
result: str = categorize_by_count(5)
assert result == "short"
def test_categorize_fifty_returns_short() -> None:
result: str = categorize_by_count(50)
assert result == "short"
def test_categorize_two_hundred_returns_medium() -> None:
result: str = categorize_by_count(200)
assert result == "medium"
# ...three more just like these
Every function does exactly the same thing. The only differences are the input number and the expected string. Six functions, six copies of the same logic with different data.
"You already know the fix for this," Emma says. "What did you do when you had the same function body repeated with different values?" James remembers: he used parameters. A function takes input, and you call it with different arguments. "Testing has the same idea. One test function, many sets of input data."
You previewed this concept in Chapter 43, Lesson 7, where you saw a parametrized test during a reading exercise. You saw it again in Chapter 46, Lesson 4, in a TDG cycle. Now you write your own.
Parametrize means running the same test with different inputs. Instead of writing six functions that all do the same thing with different numbers, you write one function and give it a table of inputs and expected outputs. pytest runs the function once for each row in the table.
@pytest.mark.parametrize is similar to @ParameterizedTest in JUnit 5 or [TestCase] in NUnit. The syntax uses a decorator with a comma-separated string of parameter names and a list of tuples for values.
The Dot-Chain: pytest.mark.parametrize
Before using the decorator, understand the name. @pytest.mark.parametrize has three parts connected by dots:
| Part | What it means |
|---|---|
pytest | The testing library |
mark | pytest's system for attaching metadata to tests |
parametrize | The specific feature: run this test multiple times with different data |
This is the same dot notation you use for note.title or math.sqrt. Each dot accesses something inside the thing before it.
Your First Parametrized Test
Here are the six tests from above, collapsed into one:
import pytest
from smartnotes.categorize import categorize_by_count
@pytest.mark.parametrize(
"word_count, expected",
[
(5, "short"),
(50, "short"),
(200, "medium"),
(500, "medium"),
(1001, "long"),
(2000, "long"),
],
)
def test_categorize_by_count(word_count: int, expected: str) -> None:
# Act
result: str = categorize_by_count(word_count)
# Assert
assert result == expected
Breaking this down:
"word_count, expected"is a string listing the parameter names, separated by a comma.- The list of tuples provides the values. Each tuple is one test case:
(5, "short")meansword_count=5andexpected="short". - The function receives
word_countandexpectedas parameters, just like a regular function. - pytest runs the function six times, once for each tuple.
Run uv run pytest -v and you see six test results:
test_categorize.py::test_categorize_by_count[5-short] PASSED
test_categorize.py::test_categorize_by_count[50-short] PASSED
test_categorize.py::test_categorize_by_count[200-medium] PASSED
test_categorize.py::test_categorize_by_count[500-medium] PASSED
test_categorize.py::test_categorize_by_count[1001-long] PASSED
test_categorize.py::test_categorize_by_count[2000-long] PASSED
Six tests, one function. Adding a seventh case means adding one tuple to the list, not writing a new function.
Multiple Parameters with Tuples
Each tuple in the parameter list maps to the names in the string. The positions must match:
@pytest.mark.parametrize(
"title, word_count, expected_category",
[
("Quick note", 50, "short"),
("Article draft", 500, "medium"),
("Thesis chapter", 2000, "long"),
],
)
def test_note_categorization(
title: str, word_count: int, expected_category: str
) -> None:
note: Note = Note(title=title, body="text", word_count=word_count)
result: str = categorize_by_count(note.word_count)
assert result == expected_category
Three parameters per tuple, three parameter names in the string, three function parameters. The tuple ("Quick note", 50, "short") maps title="Quick note", word_count=50, expected_category="short".
Test IDs for Readable Output
By default, pytest generates IDs from the parameter values: [5-short], [50-short], etc. These are readable for simple values, but for complex data they become cryptic. You can assign explicit IDs with the ids parameter:
@pytest.mark.parametrize(
"word_count, expected",
[
(5, "short"),
(200, "medium"),
(1001, "long"),
],
ids=["tiny-note", "average-note", "research-paper"],
)
def test_categorize_by_count(word_count: int, expected: str) -> None:
result: str = categorize_by_count(word_count)
assert result == expected
Now the output reads:
test_categorize.py::test_categorize_by_count[tiny-note] PASSED
test_categorize.py::test_categorize_by_count[average-note] PASSED
test_categorize.py::test_categorize_by_count[research-paper] PASSED
Custom IDs help when you have many cases and need to quickly identify which scenario failed. The ID appears in the failure report.
Markers: Categorizing Tests
Markers are labels you attach to tests. @pytest.mark.parametrize is one marker. pytest includes several built-in markers, and you can define your own.
@pytest.mark.skip
Skip a test entirely. Useful when a test is temporarily broken or depends on a feature you have not implemented yet:
@pytest.mark.skip(reason="Waiting for tag filtering feature")
def test_filter_by_tag(sample_note: Note) -> None:
result: list[Note] = filter_by_tag([sample_note], "python")
assert len(result) == 1
pytest reports this as "skipped" instead of "passed" or "failed." The reason string appears in verbose output so you (and your teammates) know why it was skipped.
Custom Markers
You can define your own markers. A common pattern is marking slow tests:
@pytest.mark.slow
def test_process_large_dataset() -> None:
notes: list[Note] = generate_notes(10000)
result: int = len(notes)
assert result == 10000
Then run only fast tests by excluding slow ones:
uv run pytest -m "not slow"
Or run only slow tests:
uv run pytest -m "slow"
To register custom markers and avoid warnings, add them to pyproject.toml:
[tool.pytest.ini_options]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
]
Combining Parametrize and Markers
You can stack decorators. A parametrized test can also have a marker:
@pytest.mark.slow
@pytest.mark.parametrize(
"count",
[100, 1000, 10000],
)
def test_generate_notes_various_sizes(count: int) -> None:
notes: list[Note] = generate_notes(count)
assert len(notes) == count
This creates three tests, all marked as slow. Running uv run pytest -m "not slow" skips all three.
PRIMM-AI+ Practice: Parametrize Conversion
Predict [AI-FREE]
Look at this parametrized test without running it. Predict how many individual tests pytest will run, and predict the ID that will appear for each. Write your predictions and a confidence score from 1 to 5 before checking.
@pytest.mark.parametrize(
"body, expected_count",
[
("hello", 1),
("hello world", 2),
("one two three four", 4),
("", 0),
],
ids=["single-word", "two-words", "four-words", "empty-string"],
)
def test_count_words(body: str, expected_count: int) -> None:
result: int = count_words(body)
assert result == expected_count
Questions:
- How many tests will pytest run?
- What will the test ID be for the empty string case?
- If the
count_words("")call returns 1 instead of 0, which test ID will show as FAILED?
Check your predictions
- Four tests. One per tuple in the list.
- The empty string case has ID
empty-string(from theidslist). - The failing test ID would be
test_count_words[empty-string]. This is why custom IDs are useful: you immediately know which scenario broke.
Run
Take these six repetitive tests and convert them into one parametrized test:
def test_cat_5() -> None:
assert categorize_by_count(5) == "short"
def test_cat_50() -> None:
assert categorize_by_count(50) == "short"
def test_cat_200() -> None:
assert categorize_by_count(200) == "medium"
def test_cat_500() -> None:
assert categorize_by_count(500) == "medium"
def test_cat_1001() -> None:
assert categorize_by_count(1001) == "long"
def test_cat_2000() -> None:
assert categorize_by_count(2000) == "long"
Write the parametrized version in test_parametrize_practice.py. Run uv run pytest test_parametrize_practice.py -v and confirm all six cases pass.
Investigate
Add a deliberate wrong expectation: change (200, "medium") to (200, "short"). Run pytest. Read the failure output. Notice how pytest shows you the parameter values and the ID of the failing case. Change it back.
Modify
Add two boundary cases to your parametrized test:
(0, "short")for zero words(1000, "medium")for exactly the threshold
Predict whether they pass before running. If one fails, what does that tell you about the boundary in categorize_by_count?
Make [Mastery Gate]
Without looking at any examples, write a parametrized test for a function called grade_score(score: int) -> str that returns letter grades:
- 90 and above:
"A" - 80 to 89:
"B" - 70 to 79:
"C" - Below 70:
"F"
Your parametrized test must:
- Have at least 6 cases (including at least one boundary value for each grade)
- Use custom
idsfor each case - Pass all cases when run with
uv run pytest -v
Try With AI
If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.
Prompt 1: Convert Repetitive Tests
I have these five test functions that all do the same thing
with different inputs:
def test_short_1() -> None:
assert categorize_by_count(10) == "short"
def test_short_2() -> None:
assert categorize_by_count(99) == "short"
def test_medium_1() -> None:
assert categorize_by_count(200) == "medium"
def test_medium_2() -> None:
assert categorize_by_count(999) == "medium"
def test_long_1() -> None:
assert categorize_by_count(1500) == "long"
Convert these into one @pytest.mark.parametrize test with
custom IDs. Keep type annotations on all variables.
Review the AI's output. Check: does it use a single function? Are the parameter names in the decorator string? Does each tuple match the function parameters? Are the IDs descriptive?
What you're learning: You are evaluating AI-generated parametrize syntax against the pattern you just learned.
Prompt 2: Add Boundary Cases
My parametrized test covers word counts 10, 99, 200, 999, and 1500.
What boundary values am I missing? Add them to the parametrize list
and explain why each boundary matters.
Read the AI's suggestions. Boundary values typically include 0, the exact threshold (e.g., 100, 1000), and one value on each side of the threshold (99 vs. 100, 999 vs. 1000). Compare its suggestions to your understanding of where categorize_by_count changes behavior.
What you're learning: You are using the AI to identify gaps in your test coverage, which is a skill you will formalize in Lesson 5 with coverage reports.
Key Takeaways
-
@pytest.mark.parametrizeruns one test function with many inputs. Each tuple in the parameter list becomes a separate test case. Six tuples, six tests, one function. -
The dot-chain
pytest.mark.parametrizereads as: the parametrize feature of pytest's mark system. This is the same dot notation you use everywhere in Python. -
Custom IDs make failure reports readable. Without IDs, pytest auto-generates them from values. With IDs, you see
[tiny-note]instead of[5-short]. -
Markers categorize tests.
@pytest.mark.skipskips a test. Custom markers like@pytest.mark.slowlet you run subsets withpytest -m "not slow". -
Parametrize collapses duplication. If you have three or more test functions with the same structure and different data, parametrize is the right tool.
Looking Ahead
You can now test many inputs efficiently. But what about inputs that should cause errors? In Lesson 4, you will learn pytest.raises, which tests that your code raises the correct exception for invalid inputs.