You're writing tests for a function. The logic is the same, but you need to check multiple inputs. Without parametrization, you end up with repetitive code:
def test_add_positive_numbers():
assert add(2, 3) == 5
def test_add_negative_numbers():
assert add(-1, -1) == -2
def test_add_mixed_numbers():
assert add(-1, 5) == 4Three functions that do essentially the same thing. There's a better way.
Basic Parametrization
The @pytest.mark.parametrize decorator runs your test multiple times with different arguments:
import pytest
@pytest.mark.parametrize("a,b,expected", [
(2, 3, 5),
(-1, -1, -2),
(-1, 5, 4),
(0, 0, 0),
])
def test_add(a, b, expected):
assert add(a, b) == expectedOne test function, four test cases. The output shows each case separately:
test_math.py::test_add[2-3-5] PASSED
test_math.py::test_add[-1--1--2] PASSED
test_math.py::test_add[-1-5-4] PASSED
test_math.py::test_add[0-0-0] PASSED
Naming Test Cases
Those auto-generated names aren't great. Use pytest.param with id for clarity:
@pytest.mark.parametrize("input,expected", [
pytest.param("hello", "HELLO", id="lowercase"),
pytest.param("WORLD", "WORLD", id="already_upper"),
pytest.param("MiXeD", "MIXED", id="mixed_case"),
pytest.param("", "", id="empty_string"),
])
def test_uppercase(input, expected):
assert input.upper() == expectedNow the output reads naturally:
test_strings.py::test_uppercase[lowercase] PASSED
test_strings.py::test_uppercase[already_upper] PASSED
test_strings.py::test_uppercase[mixed_case] PASSED
test_strings.py::test_uppercase[empty_string] PASSED
Multiple Parametrize Decorators
Stack decorators to test all combinations:
@pytest.mark.parametrize("x", [1, 2])
@pytest.mark.parametrize("y", [10, 20])
def test_multiply(x, y):
result = x * y
assert result == x * yThis generates four tests: (1,10), (1,20), (2,10), (2,20). Useful for testing interactions between independent parameters.
Testing Edge Cases and Errors
Parametrization shines for edge case testing:
@pytest.mark.parametrize("value,expected", [
pytest.param(None, False, id="none"),
pytest.param("", False, id="empty_string"),
pytest.param(" ", False, id="whitespace"),
pytest.param("hello", True, id="valid_string"),
pytest.param(0, False, id="zero"),
pytest.param(1, True, id="positive_int"),
pytest.param([], False, id="empty_list"),
pytest.param([1], True, id="non_empty_list"),
])
def test_is_truthy(value, expected):
assert bool(value) == expectedFor testing exceptions:
@pytest.mark.parametrize("value,error", [
pytest.param("not_a_number", ValueError, id="invalid_string"),
pytest.param(None, TypeError, id="none_value"),
pytest.param(float("inf"), OverflowError, id="infinity"),
])
def test_parse_int_errors(value, error):
with pytest.raises(error):
parse_strict_int(value)Parameterizing Fixtures
Combine parametrization with fixtures for powerful patterns:
@pytest.fixture
def database(request):
db_type = request.param
if db_type == "sqlite":
return SqliteDatabase(":memory:")
elif db_type == "postgres":
return PostgresDatabase("test_db")
@pytest.mark.parametrize("database", ["sqlite", "postgres"], indirect=True)
def test_insert(database):
database.insert({"key": "value"})
assert database.get("key") == "value"The indirect=True tells pytest to pass the parameter to the fixture, not directly to the test.
Real-World Example: API Testing
Testing API endpoints with various inputs:
@pytest.mark.parametrize("payload,status_code,error_field", [
pytest.param(
{"email": "valid@example.com", "password": "secure123"},
200,
None,
id="valid_registration"
),
pytest.param(
{"email": "invalid-email", "password": "secure123"},
400,
"email",
id="invalid_email_format"
),
pytest.param(
{"email": "valid@example.com", "password": "123"},
400,
"password",
id="password_too_short"
),
pytest.param(
{"email": "", "password": "secure123"},
400,
"email",
id="missing_email"
),
])
def test_register_user(client, payload, status_code, error_field):
response = client.post("/api/register", json=payload)
assert response.status_code == status_code
if error_field:
assert error_field in response.json["errors"]Combining with Markers
Mark specific parameter combinations:
@pytest.mark.parametrize("n,expected", [
(1, 1),
(10, 55),
pytest.param(50, 12586269025, marks=pytest.mark.slow),
pytest.param(100, 354224848179261915075, marks=pytest.mark.slow),
])
def test_fibonacci(n, expected):
assert fibonacci(n) == expectedSkip slow tests in quick runs with pytest -m "not slow".
Loading Test Data from Files
For extensive test cases, load from external files:
import json
from pathlib import Path
def load_test_cases():
data = Path("tests/data/validation_cases.json").read_text()
cases = json.loads(data)
return [
pytest.param(case["input"], case["expected"], id=case["name"])
for case in cases
]
@pytest.mark.parametrize("input,expected", load_test_cases())
def test_validate(input, expected):
assert validate(input) == expectedBest Practices
- Use descriptive IDs:
id="empty_list"beatsid="case_7" - Group related cases: Keep parameters for one logical feature together
- Don't over-parametrize: If cases need different assertions, write separate tests
- Consider readability: A simple list of tuples works for obvious cases; use
pytest.paramfor complex ones
Parametrization eliminates copy-paste testing. You define the pattern once and let pytest run it against all your cases. When a bug appears, adding a regression test is just one more tuple in the list.