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 ids argument to pytest.mark.parametrize.

  • Using a list of ids, passed as ids argument to pytest.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.