Skip to content

Commit

Permalink
python: allow adding parameter names to parametrized test IDs
Browse files Browse the repository at this point in the history
By default, only the parameter's values make it into parametrized test
IDs. The parameter names don't. Since parameter values do not always
speak for themselves, the test function + test ID are often not
descriptive/expressive.

Allowing parameter name=value pairs in the test ID optionally to get an idea
what parameters a test gets passed is beneficial. So add a kwarg
`id_names` to @pytest.mark.parametrize() / pytest.Metafunc.parametrize(). It defaults
to `False` to keep the auto-generated ID as before. If set to `True`,
the argument parameter=value pairs in the auto-generated test
IDs are enabled. Calling parametrize() with `ids` and `id_names=True` is
considered an error.

Auto-generated test ID with `id_names=False` (default behavior as
before):

  test_something[100-10-True-False-True]

Test ID with `id_names=True`:

  test_something[speed_down=100-speed_up=10-foo=True-bar=False-baz=True]

Signed-off-by: Bastian Krause <[email protected]>
  • Loading branch information
Bastian-Krause committed Jan 6, 2025
1 parent 5d58b1f commit b15cd74
Show file tree
Hide file tree
Showing 5 changed files with 83 additions and 27 deletions.
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Aviral Verma
Aviv Palivoda
Babak Keyvani
Barney Gale
Bastian Krause
Ben Brown
Ben Gartner
Ben Leith
Expand Down
1 change: 1 addition & 0 deletions changelog/13055.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``@pytest.mark.parametrize()`` and ``pytest.Metafunc.parametrize()`` now support the ``id_names`` argument enabling auto-generated test IDs consisting of parameter name=value pairs.
43 changes: 27 additions & 16 deletions doc/en/example/parametrize.rst
Original file line number Diff line number Diff line change
Expand Up @@ -111,20 +111,26 @@ the argument name:
assert diff == expected
@pytest.mark.parametrize("a,b,expected", testdata, ids=["forward", "backward"])
@pytest.mark.parametrize("a,b,expected", testdata, id_names=True)
def test_timedistance_v1(a, b, expected):
diff = a - b
assert diff == expected
@pytest.mark.parametrize("a,b,expected", testdata, ids=["forward", "backward"])
def test_timedistance_v2(a, b, expected):
diff = a - b
assert diff == expected
def idfn(val):
if isinstance(val, (datetime,)):
# note this wouldn't show any hours/minutes/seconds
return val.strftime("%Y%m%d")
@pytest.mark.parametrize("a,b,expected", testdata, ids=idfn)
def test_timedistance_v2(a, b, expected):
def test_timedistance_v3(a, b, expected):
diff = a - b
assert diff == expected
Expand All @@ -140,16 +146,19 @@ the argument name:
),
],
)
def test_timedistance_v3(a, b, expected):
def test_timedistance_v4(a, b, expected):
diff = a - b
assert diff == expected
In ``test_timedistance_v0``, we let pytest generate the test IDs.

In ``test_timedistance_v1``, we specified ``ids`` as a list of strings which were
In ``test_timedistance_v1``, we let pytest generate the test IDs using argument
name/value pairs.

In ``test_timedistance_v2``, we specified ``ids`` as a list of strings which were
used as the test IDs. These are succinct, but can be a pain to maintain.

In ``test_timedistance_v2``, we specified ``ids`` as a function that can generate a
In ``test_timedistance_v3``, we specified ``ids`` as a function that can generate a
string representation to make part of the test ID. So our ``datetime`` values use the
label generated by ``idfn``, but because we didn't generate a label for ``timedelta``
objects, they are still using the default pytest representation:
Expand All @@ -160,22 +169,24 @@ objects, they are still using the default pytest representation:
=========================== test session starts ============================
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
rootdir: /home/sweet/project
collected 8 items
collected 10 items
<Dir parametrize.rst-204>
<Module test_time.py>
<Function test_timedistance_v0[a0-b0-expected0]>
<Function test_timedistance_v0[a1-b1-expected1]>
<Function test_timedistance_v1[forward]>
<Function test_timedistance_v1[backward]>
<Function test_timedistance_v2[20011212-20011211-expected0]>
<Function test_timedistance_v2[20011211-20011212-expected1]>
<Function test_timedistance_v3[forward]>
<Function test_timedistance_v3[backward]>
======================== 8 tests collected in 0.12s ========================
In ``test_timedistance_v3``, we used ``pytest.param`` to specify the test IDs
<Function test_timedistance_v1[a=a0-b=b0-expected=expected0]>
<Function test_timedistance_v1[a=a1-b=b1-expected=expected1]>
<Function test_timedistance_v2[forward]>
<Function test_timedistance_v2[backward]>
<Function test_timedistance_v3[20011212-20011211-expected0]>
<Function test_timedistance_v3[20011211-20011212-expected1]>
<Function test_timedistance_v4[forward]>
<Function test_timedistance_v4[backward]>
======================== 10 tests collected in 0.12s =======================
In ``test_timedistance_v4``, we used ``pytest.param`` to specify the test IDs
together with the actual data, instead of listing them separately.

A quick port of "testscenarios"
Expand Down
35 changes: 28 additions & 7 deletions src/_pytest/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -884,18 +884,19 @@ class IdMaker:
# Used only for clearer error messages.
func_name: str | None

def make_unique_parameterset_ids(self) -> list[str]:
def make_unique_parameterset_ids(self, id_names: bool = False) -> list[str]:
"""Make a unique identifier for each ParameterSet, that may be used to
identify the parametrization in a node ID.
Format is <prm_1_token>-...-<prm_n_token>[counter], where prm_x_token is
Format is [<prm_1>=]<prm_1_token>-...-[<prm_n>=]<prm_n_token>[counter],
where prm_x is <argname> (only for id_names=True) and prm_x_token is
- user-provided id, if given
- else an id derived from the value, applicable for certain types
- else <argname><parameterset index>
The counter suffix is appended only in case a string wouldn't be unique
otherwise.
"""
resolved_ids = list(self._resolve_ids())
resolved_ids = list(self._resolve_ids(id_names=id_names))
# All IDs must be unique!
if len(resolved_ids) != len(set(resolved_ids)):
# Record the number of occurrences of each ID.
Expand All @@ -919,7 +920,7 @@ def make_unique_parameterset_ids(self) -> list[str]:
), f"Internal error: {resolved_ids=}"
return resolved_ids

def _resolve_ids(self) -> Iterable[str]:
def _resolve_ids(self, id_names: bool = False) -> Iterable[str]:
"""Resolve IDs for all ParameterSets (may contain duplicates)."""
for idx, parameterset in enumerate(self.parametersets):
if parameterset.id is not None:
Expand All @@ -930,8 +931,9 @@ def _resolve_ids(self) -> Iterable[str]:
yield self._idval_from_value_required(self.ids[idx], idx)
else:
# ID not provided - generate it.
idval_func = self._idval_named if id_names else self._idval
yield "-".join(
self._idval(val, argname, idx)
idval_func(val, argname, idx)
for val, argname in zip(parameterset.values, self.argnames)
)

Expand All @@ -948,6 +950,11 @@ def _idval(self, val: object, argname: str, idx: int) -> str:
return idval
return self._idval_from_argname(argname, idx)

def _idval_named(self, val: object, argname: str, idx: int) -> str:
"""Make an ID in argname=value format for a parameter in a
ParameterSet."""
return "=".join((argname, self._idval(val, argname, idx)))

Check warning on line 956 in src/_pytest/python.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/python.py#L956

Added line #L956 was not covered by tests

def _idval_from_function(self, val: object, argname: str, idx: int) -> str | None:
"""Try to make an ID for a parameter in a ParameterSet using the
user-provided id callable, if given."""
Expand Down Expand Up @@ -1141,6 +1148,7 @@ def parametrize(
indirect: bool | Sequence[str] = False,
ids: Iterable[object | None] | Callable[[Any], object | None] | None = None,
scope: _ScopeName | None = None,
id_names: bool = False,
*,
_param_mark: Mark | None = None,
) -> None:
Expand Down Expand Up @@ -1205,6 +1213,11 @@ def parametrize(
The scope is used for grouping tests by parameter instances.
It will also override any fixture-function defined scope, allowing
to set a dynamic scope using test context or configuration.
:param id_names:
Whether the argument names should be part of the auto-generated
ids. Defaults to ``False``. Must not be ``True`` if ``ids`` is
given.
"""
argnames, parametersets = ParameterSet._for_parametrize(
argnames,
Expand All @@ -1228,6 +1241,9 @@ def parametrize(
else:
scope_ = _find_parametrized_scope(argnames, self._arg2fixturedefs, indirect)

if id_names and ids is not None:
fail("'id_names' must not be combined with 'ids'", pytrace=False)

Check warning on line 1245 in src/_pytest/python.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/python.py#L1245

Added line #L1245 was not covered by tests

self._validate_if_using_arg_names(argnames, indirect)

# Use any already (possibly) generated ids with parametrize Marks.
Expand All @@ -1237,7 +1253,11 @@ def parametrize(
ids = generated_ids

ids = self._resolve_parameter_set_ids(
argnames, ids, parametersets, nodeid=self.definition.nodeid
argnames,
ids,
parametersets,
nodeid=self.definition.nodeid,
id_names=id_names,
)

# Store used (possibly generated) ids with parametrize Marks.
Expand Down Expand Up @@ -1322,6 +1342,7 @@ def _resolve_parameter_set_ids(
ids: Iterable[object | None] | Callable[[Any], object | None] | None,
parametersets: Sequence[ParameterSet],
nodeid: str,
id_names: bool,
) -> list[str]:
"""Resolve the actual ids for the given parameter sets.
Expand Down Expand Up @@ -1356,7 +1377,7 @@ def _resolve_parameter_set_ids(
nodeid=nodeid,
func_name=self.function.__name__,
)
return id_maker.make_unique_parameterset_ids()
return id_maker.make_unique_parameterset_ids(id_names=id_names)

def _validate_ids(
self,
Expand Down
30 changes: 26 additions & 4 deletions testing/python/metafunc.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,18 +199,28 @@ def find_scope(argnames, indirect):
)
assert find_scope(["mixed_fix"], indirect=True) == Scope.Class

def test_parametrize_and_id(self) -> None:
@pytest.mark.parametrize("id_names", (False, True))
def test_parametrize_and_id(self, id_names: bool) -> None:
def func(x, y):
pass

metafunc = self.Metafunc(func)

metafunc.parametrize("x", [1, 2], ids=["basic", "advanced"])
metafunc.parametrize("y", ["abc", "def"])
metafunc.parametrize("y", ["abc", "def"], id_names=id_names)
ids = [x.id for x in metafunc._calls]
assert ids == ["basic-abc", "basic-def", "advanced-abc", "advanced-def"]
if id_names:
assert ids == [
"basic-y=abc",
"basic-y=def",
"advanced-y=abc",
"advanced-y=def",
]
else:
assert ids == ["basic-abc", "basic-def", "advanced-abc", "advanced-def"]

def test_parametrize_and_id_unicode(self) -> None:
@pytest.mark.parametrize("id_names", (False, True))
def test_parametrize_and_id_unicode(self, id_names: bool) -> None:
"""Allow unicode strings for "ids" parameter in Python 2 (##1905)"""

def func(x):
Expand All @@ -221,6 +231,18 @@ def func(x):
ids = [x.id for x in metafunc._calls]
assert ids == ["basic", "advanced"]

def test_parametrize_with_bad_ids_name_combination(self) -> None:
def func(x):
pass

metafunc = self.Metafunc(func)

with pytest.raises(
fail.Exception,
match="'id_names' must not be combined with 'ids'",
):
metafunc.parametrize("x", [1, 2], ids=["basic", "advanced"], id_names=True)

def test_parametrize_with_wrong_number_of_ids(self) -> None:
def func(x, y):
pass
Expand Down

0 comments on commit b15cd74

Please sign in to comment.