Specify ID or Name for pytest Parameter Set¶
In pytest, you can add a name or a message to a parameter set to make it easier to identify which parameter set is being run. This can be particularly useful when you have many parameter sets or complex test cases.
Custom identifiers for pytest parametrization could be specified in different ways:
Using a callback function passed as
idsargument topytest.mark.parametrize.Using a list of ids, passed as
idsargument topytest.mark.parametrize.Using
pytest.param
First let’s see how pytest is creating automatic identifiers:
Parameterization with automatic identifiers¶
If you do not specify parameterset identifier, pytest will generate automatically such identifiers dinamically, based on the parameter values. Here’s an example of how this works:
import pytest
test_data = [
(1, 2),
(2, 4),
(3, 6)
]
@pytest.mark.parametrize(
"input, expected",
test_data
)
def test_addition_automatic_param_ids(input, expected):
assert input + input == expected
Running this test produces following result:
============================= test session starts ==============================
platform linux -- Python 3.10.13, pytest-8.3.2, pluggy-1.5.0 -- /home/codespace/.python/current/bin/python3
cachedir: .pytest_cache
django: version: 5.1, settings: blogapi.settings (from ini)
rootdir: /workspaces/django-for-apis-cookbook-with-blog
configfile: pytest.ini
plugins: anyio-4.4.0, cov-5.0.0, django-4.8.0
collecting ... collected 3 items
src/tests/recipes/test_custom_parameterset_identifiers.py::test_addition_automatic_param_ids[1-2] PASSED [ 33%]
src/tests/recipes/test_custom_parameterset_identifiers.py::test_addition_automatic_param_ids[2-4] PASSED [ 66%]
src/tests/recipes/test_custom_parameterset_identifiers.py::test_addition_automatic_param_ids[3-6] PASSED [100%]
============================== 3 passed in 0.05s ===============================
Each test parameterset has been assigned name/identifier by pytest.
Provide custom identifiers with pytest.param¶
import pytest
@pytest.mark.parametrize(
"input, expected",
[
pytest.param(1, 2, id="one_plus_one"),
pytest.param(2, 4, id="two_plus_two"),
pytest.param(3, 6, id="three_plus_three")
]
)
def test_addition_custom_id(input, expected):
assert input + input == expected
The result from running above test function is:
============================= test session starts ==============================
platform linux -- Python 3.10.13, pytest-8.3.2, pluggy-1.5.0 -- /home/codespace/.python/current/bin/python3
cachedir: .pytest_cache
django: version: 5.1, settings: blogapi.settings (from ini)
rootdir: /workspaces/django-for-apis-cookbook-with-blog
configfile: pytest.ini
plugins: anyio-4.4.0, cov-5.0.0, django-4.8.0
collecting ... collected 3 items
src/tests/recipes/test_custom_parameterset_identifiers.py::test_addition_custom_id[one_plus_one] PASSED [ 33%]
src/tests/recipes/test_custom_parameterset_identifiers.py::test_addition_custom_id[two_plus_two] PASSED [ 66%]
src/tests/recipes/test_custom_parameterset_identifiers.py::test_addition_custom_id[three_plus_three] PASSED [100%]
============================== 3 passed in 0.06s ===============================
As you can see the identifiers we provided in the pytest.param decorator are being used by pytest.
Provide custom identifiers with ids parameter¶
You can spcify explicit identifiers for parametersets using the ids parameter of the pytest.mark.parametrize fixture:
@pytest.mark.parametrize(
"input, expected",
test_data,
ids = ("one_plus_one", "two_plus_two", "three_plus_three")
)
def test_addition_custom_ids_list(input, expected):
assert input + input == expected
Produces following result:
============================= test session starts ==============================
platform linux -- Python 3.10.13, pytest-8.3.2, pluggy-1.5.0 -- /home/codespace/.python/current/bin/python3
cachedir: .pytest_cache
django: version: 5.1, settings: blogapi.settings (from ini)
rootdir: /workspaces/django-for-apis-cookbook-with-blog
configfile: pytest.ini
plugins: anyio-4.4.0, cov-5.0.0, django-4.8.0
collecting ... collected 3 items
src/tests/recipes/test_custom_parameterset_identifiers.py::test_addition_custom_ids_list[one_plus_one] PASSED [ 33%]
src/tests/recipes/test_custom_parameterset_identifiers.py::test_addition_custom_ids_list[two_plus_two] PASSED [ 66%]
src/tests/recipes/test_custom_parameterset_identifiers.py::test_addition_custom_ids_list[three_plus_three] PASSED [100%]
============================== 3 passed in 0.07s ===============================
Good Practice: Use Dictionary to Define Test Parameters¶
Let’s assume we need to test a function which can be used to withdraw money from a bank account.
from decimal import Decimal
class BadWithdrawRequest(Exception):
pass
class NegativeOrZeroAmount(BadWithdrawRequest):
def __init__(self, message="Cannot withdraw negative or zero amount."):
super().__init__(message)
class NegativeOrZeroBalance(BadWithdrawRequest):
def __init__(self, message="Cannot withdraw from negative or zero balance."):
super().__init__(message)
class InsufficientBalance(BadWithdrawRequest):
def __init__(self, message="Insufficient balance to complete withdrawal."):
super().__init__(message)
def withdraw(balance, amount):
if not isinstance(balance, Decimal) or not isinstance(amount, Decimal):
raise TypeError("Both balance and amount must be Decimal instances.")
if amount <= 0:
raise NegativeOrZeroAmount()
if balance <= 0:
raise NegativeOrZeroBalance()
if balance < amount:
raise InsufficientBalance()
return balance - amount
We can create two test functions - one for testing success and one for testing failure. The two functions are parametrized.
We could use dictionary to define the function parameters. The keys of the dictionary are the ids of the parameter set, expressing the behavior being tested and the value is the actual parameterset.
import pytest
params_for_test_withdraw_success = {
"enough-balance": (Decimal(10), Decimal(5), Decimal(5)),
"full-balance": (Decimal(5), Decimal(5), Decimal(0)),
"rounding-error": (Decimal("0.3"), Decimal("0.1"), Decimal("0.20")),
}
params_for_test_withdraw_failure = {
"non-decimal-balance": (5, Decimal(-5), TypeError, "(?i)both balance and amount must be decimal instances"),
"non-decimal-amount": (5, Decimal(-5), TypeError, "(?i)both balance and amount must be decimal instances"),
"negative-amount": (Decimal(5), Decimal(-5), NegativeOrZeroAmount, "negative or zero amount"),
"insufficient-balance": (Decimal(5), Decimal(10), InsufficientBalance, "(?i)insufficient balance"),
"zero-balance": (Decimal(0), Decimal(5), NegativeOrZeroBalance, "negative or zero balance"),
"negative-balance": (Decimal(-5), Decimal(5), NegativeOrZeroBalance, "negative or zero balance"),
}
@pytest.mark.parametrize(
"balance, amount, expect",
params_for_test_withdraw_success.values(),
ids=params_for_test_withdraw_success.keys(),
)
def test_withdraw_success(balance, amount, expect):
new_balance = withdraw(balance, amount)
assert new_balance == expect
@pytest.mark.parametrize(
"balance, amount, expect_raises, raise_matches",
params_for_test_withdraw_failure.values(),
ids=params_for_test_withdraw_failure.keys(),
)
def test_withdraw_failure(balance, amount, expect_raises, raise_matches):
with pytest.raises(expect_raises, match=raise_matches):
withdraw(balance, amount)
Running the tests produces following output:
============================= test session starts ==============================
platform linux -- Python 3.10.13, pytest-8.3.2, pluggy-1.5.0 -- /home/codespace/.python/current/bin/python3
cachedir: .pytest_cache
django: version: 5.1, settings: blogapi.settings (from ini)
rootdir: /workspaces/django-for-apis-cookbook-with-blog
configfile: pytest.ini
plugins: anyio-4.4.0, cov-5.0.0, django-4.8.0
collecting ... collected 9 items
src/tests/sandbox/test_parametrize_with_dictionary.py::test_withdraw_success[enough-balance] PASSED [ 11%]
src/tests/sandbox/test_parametrize_with_dictionary.py::test_withdraw_success[full-balance] PASSED [ 22%]
src/tests/sandbox/test_parametrize_with_dictionary.py::test_withdraw_success[rounding-error] PASSED [ 33%]
src/tests/sandbox/test_parametrize_with_dictionary.py::test_withdraw_failure[non-decimal-balance] PASSED [ 44%]
src/tests/sandbox/test_parametrize_with_dictionary.py::test_withdraw_failure[non-decimal-amount] PASSED [ 55%]
src/tests/sandbox/test_parametrize_with_dictionary.py::test_withdraw_failure[negative-amount] PASSED [ 66%]
src/tests/sandbox/test_parametrize_with_dictionary.py::test_withdraw_failure[insufficient-balance] PASSED [ 77%]
src/tests/sandbox/test_parametrize_with_dictionary.py::test_withdraw_failure[zero-balance] PASSED [ 88%]
src/tests/sandbox/test_parametrize_with_dictionary.py::test_withdraw_failure[negative-balance] PASSED [100%]
============================== 9 passed in 0.10s ===============================
Generate Dynamic Identifiers¶
You might also want to generate identifiers dynamically based on the parameter values. Here’s an example of how you can do that:
import pytest
test_data = [
(1, 2),
(2, 4),
(3, 6)
]
@pytest.mark.parametrize(
"input, expected",
test_data,
ids = (f"{input}_plus_{input}_expected_{expected}" for input, expected in test_data)
)
def test_addition_dynamic_id1(input, expected):
assert input + input == expected
And the output:
============================= test session starts ==============================
platform linux -- Python 3.10.13, pytest-8.3.2, pluggy-1.5.0 -- /home/codespace/.python/current/bin/python3
cachedir: .pytest_cache
django: version: 5.1, settings: blogapi.settings (from ini)
rootdir: /workspaces/django-for-apis-cookbook-with-blog
configfile: pytest.ini
plugins: anyio-4.4.0, cov-5.0.0, django-4.8.0
collecting ... collected 3 items
src/tests/sandbox/test_custom_parameterset_identifiers.py::test_addition_dynamic_id1[1_plus_1_expected_2] PASSED [ 33%]
src/tests/sandbox/test_custom_parameterset_identifiers.py::test_addition_dynamic_id1[2_plus_2_expected_4] PASSED [ 66%]
src/tests/sandbox/test_custom_parameterset_identifiers.py::test_addition_dynamic_id1[3_plus_3_expected_6] PASSED [100%]
============================== 3 passed in 0.06s ===============================
Assign Custom Idenfiers for Parameterized pytest Fixutres¶
For parametrized fixtures, you could use the ids parameter of the fixture decorator and access the current parameter value by inspecting request.param:
@pytest.fixture(params=["done", "in prog", "todo"], ids=["State: Done", "State: In Progress", "State: To Do"])
def start_state(request):
return request.param
def test_finish(cards_db, start_state):
c = Card('write a book', state=start_sate)
index = cards_db.add_card(c)
cards_db.finish(index)
card = cards_db.get_card(index)
assert card.state == "done"
Conclusion¶
By using pytest.param with the id argument and including custom messages in assertions, you can make your pytest test outputs more informative and easier to understand.