Look back at the validation tests we might already be tempted to write from the previous article: one test for "empty title is rejected," another for "password too short is rejected," another for "username too short is rejected" — the pattern is ALWAYS the same, only the input and expected result differ. Writing a separate test function for each case is wasteful and easy to let drift out of sync. In this article, we clean that up into one much leaner test.
The Problem with Test Duplication
def test_register_username_too_short(client):
response = client.post("/auth/register", json={"username": "al", "password": "supersecret123"})
assert response.status_code == 422
def test_register_password_too_short(client):
response = client.post("/auth/register", json={"username": "alice", "password": "short"})
assert response.status_code == 422
def test_register_missing_password(client):
response = client.post("/auth/register", json={"username": "alice"})
assert response.status_code == 422
# ...and so on, the identical structure repeated over and over
The problem isn't just "a lot of lines" — every time the WAY we assert changes (say we want to add an extra check), we have to change it in EVERY one of these functions. Duplication like this is a strong candidate for merging.
pytest.mark.parametrize — One Test, Many Cases
import pytest
@pytest.mark.parametrize(
("payload", "expected_status"),
[
pytest.param({"username": "al", "password": "supersecret123"}, 422, id="username-too-short"),
pytest.param({"username": "alice", "password": "short"}, 422, id="password-too-short"),
pytest.param({"username": "alice"}, 422, id="missing-password"),
pytest.param({"password": "supersecret123"}, 422, id="missing-username"),
pytest.param({"username": "alice", "password": "supersecret123"}, 201, id="valid-payload"),
],
)
def test_register_validation(client: TestClient, payload: dict, expected_status: int):
response = client.post("/auth/register", json=payload)
assert response.status_code == expected_status
This single test function runs FIVE times, each with a different payload and expected_status — pytest treats them as five SEPARATE tests, each with its own pass/fail result:
pytest tests/integration/test_auth.py::test_register_validation -v
test_register_validation[username-too-short] PASSED
test_register_validation[password-too-short] PASSED
test_register_validation[missing-password] PASSED
test_register_validation[missing-username] PASSED
test_register_validation[valid-payload] PASSED
Why pytest.param(..., id=...) Instead of a Plain Tuple?
The id parameter is technically optional (without it, pytest auto-generates an ID from the Python representation of the objects, which is often long and unclear). But giving an explicit ID makes a huge difference to the DEBUGGING EXPERIENCE: compare test_register_validation[username-too-short] FAILED with test_register_validation[payload0] FAILED — the first immediately tells you which scenario is broken, without needing to open the test code first.
Another Example: Systematic Validation Boundaries
Parametrization is a great fit for systematically testing the BOUNDARY of a validation rule — not just "one bad example, one good example," but PRECISELY around the boundary point:
@pytest.mark.parametrize(
("title", "expected_status"),
[
pytest.param("", 422, id="empty-title"),
pytest.param("a", 201, id="single-character-title"),
pytest.param("a" * 200, 201, id="title-at-max-boundary"),
pytest.param("a" * 201, 422, id="title-over-max-boundary"),
],
)
def test_create_task_title_validation(client: TestClient, title: str, expected_status: int):
token = register_and_login(client)
response = client.post("/api/tasks", json={"title": title}, headers=auth_headers(token))
assert response.status_code == expected_status
Remember the Field(min_length=1, max_length=200) constraint on TaskCreate from the FastAPI series? These four cases test PRECISELY around that boundary: empty (fails), one character (lower bound, passes), 200 characters (upper bound, passes), 201 characters (one over the upper bound, fails). This pattern — "test exactly at the boundary, not just somewhere in the middle" — is called boundary testing, and it's often exactly at these boundary points that validation bugs hide (the classic < vs. <= mistake).
When NOT to Use Parametrization
Parametrization is the best fit when the test's STRUCTURE is identical and only the data and result differ. If each "case" needs significantly different setup steps (say one case needs two registered users while another only needs one), forcing it into a single parametrize actually makes the test harder to read than a few separate functions that each clearly state their intent. Parametrization is a tool for reducing STRUCTURAL duplication, not a goal of forcing every test into one giant function.
Summary
pytest.mark.parametrize runs one test function repeatedly with different inputs/expectations, avoiding structural duplication.pytest.param(..., id=...) gives each case a clear name, making failures far easier to read.