Skip to main content

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.

If you're new to programming

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.

If you have testing experience from another language

@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:

PartWhat it means
pytestThe testing library
markpytest's system for attaching metadata to tests
parametrizeThe 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:

  1. "word_count, expected" is a string listing the parameter names, separated by a comma.
  2. The list of tuples provides the values. Each tuple is one test case: (5, "short") means word_count=5 and expected="short".
  3. The function receives word_count and expected as parameters, just like a regular function.
  4. 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:

  1. How many tests will pytest run?
  2. What will the test ID be for the empty string case?
  3. If the count_words("") call returns 1 instead of 0, which test ID will show as FAILED?
Check your predictions
  1. Four tests. One per tuple in the list.
  2. The empty string case has ID empty-string (from the ids list).
  3. 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:

  1. Have at least 6 cases (including at least one boundary value for each grade)
  2. Use custom ids for each case
  3. Pass all cases when run with uv run pytest -v

Try With AI

Opening Claude Code

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

  1. @pytest.mark.parametrize runs one test function with many inputs. Each tuple in the parameter list becomes a separate test case. Six tuples, six tests, one function.

  2. The dot-chain pytest.mark.parametrize reads as: the parametrize feature of pytest's mark system. This is the same dot notation you use everywhere in Python.

  3. Custom IDs make failure reports readable. Without IDs, pytest auto-generates them from values. With IDs, you see [tiny-note] instead of [5-short].

  4. Markers categorize tests. @pytest.mark.skip skips a test. Custom markers like @pytest.mark.slow let you run subsets with pytest -m "not slow".

  5. 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.