diff --git a/AUTHORS b/AUTHORS
index ef36a4251b1..acd5bd38f1a 100644
--- a/AUTHORS
+++ b/AUTHORS
@@ -56,6 +56,7 @@ Aviral Verma
Aviv Palivoda
Babak Keyvani
Barney Gale
+Bastian Krause
Ben Brown
Ben Gartner
Ben Leith
diff --git a/changelog/13055.feature.rst b/changelog/13055.feature.rst
new file mode 100644
index 00000000000..7b7fdb71dc5
--- /dev/null
+++ b/changelog/13055.feature.rst
@@ -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.
diff --git a/doc/en/example/parametrize.rst b/doc/en/example/parametrize.rst
index fa43308d045..d43a841bc94 100644
--- a/doc/en/example/parametrize.rst
+++ b/doc/en/example/parametrize.rst
@@ -111,12 +111,18 @@ 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
@@ -124,7 +130,7 @@ the argument name:
@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
@@ -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:
@@ -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
-
-
-
-
-
-
-
- ======================== 8 tests collected in 0.12s ========================
-
-In ``test_timedistance_v3``, we used ``pytest.param`` to specify the test IDs
+
+
+
+
+
+
+
+
+
+ ======================== 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"
diff --git a/src/_pytest/mark/structures.py b/src/_pytest/mark/structures.py
index 1a0b3c5b5b8..81841a5f6c1 100644
--- a/src/_pytest/mark/structures.py
+++ b/src/_pytest/mark/structures.py
@@ -474,6 +474,7 @@ def __call__( # type: ignore[override]
| Callable[[Any], object | None]
| None = ...,
scope: _ScopeName | None = ...,
+ id_names: bool = ...,
) -> MarkDecorator: ...
class _UsefixturesMarkDecorator(MarkDecorator):
diff --git a/src/_pytest/python.py b/src/_pytest/python.py
index 85e3cb0ae71..7cbadfa77e2 100644
--- a/src/_pytest/python.py
+++ b/src/_pytest/python.py
@@ -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 -...-[counter], where prm_x_token is
+ Format is [=]-...-[=][counter],
+ where prm_x is (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
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.
@@ -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:
@@ -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)
)
@@ -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)))
+
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."""
@@ -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:
@@ -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,
@@ -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)
+
self._validate_if_using_arg_names(argnames, indirect)
# Use any already (possibly) generated ids with parametrize Marks.
@@ -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.
@@ -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.
@@ -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,
diff --git a/testing/python/metafunc.py b/testing/python/metafunc.py
index 4e7e441768c..65db36405ab 100644
--- a/testing/python/metafunc.py
+++ b/testing/python/metafunc.py
@@ -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):
@@ -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