Skip to content

Commit 9c12fd6

Browse files
python: allow adding parameter names to parametrized test IDs
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]>
1 parent 5d58b1f commit 9c12fd6

File tree

6 files changed

+84
-27
lines changed

6 files changed

+84
-27
lines changed

Diff for: AUTHORS

+1
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Aviral Verma
5656
Aviv Palivoda
5757
Babak Keyvani
5858
Barney Gale
59+
Bastian Krause
5960
Ben Brown
6061
Ben Gartner
6162
Ben Leith

Diff for: changelog/13055.feature.rst

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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 for: doc/en/example/parametrize.rst

+27-16
Original file line numberDiff line numberDiff line change
@@ -111,20 +111,26 @@ the argument name:
111111
assert diff == expected
112112
113113
114-
@pytest.mark.parametrize("a,b,expected", testdata, ids=["forward", "backward"])
114+
@pytest.mark.parametrize("a,b,expected", testdata, id_names=True)
115115
def test_timedistance_v1(a, b, expected):
116116
diff = a - b
117117
assert diff == expected
118118
119119
120+
@pytest.mark.parametrize("a,b,expected", testdata, ids=["forward", "backward"])
121+
def test_timedistance_v2(a, b, expected):
122+
diff = a - b
123+
assert diff == expected
124+
125+
120126
def idfn(val):
121127
if isinstance(val, (datetime,)):
122128
# note this wouldn't show any hours/minutes/seconds
123129
return val.strftime("%Y%m%d")
124130
125131
126132
@pytest.mark.parametrize("a,b,expected", testdata, ids=idfn)
127-
def test_timedistance_v2(a, b, expected):
133+
def test_timedistance_v3(a, b, expected):
128134
diff = a - b
129135
assert diff == expected
130136
@@ -140,16 +146,19 @@ the argument name:
140146
),
141147
],
142148
)
143-
def test_timedistance_v3(a, b, expected):
149+
def test_timedistance_v4(a, b, expected):
144150
diff = a - b
145151
assert diff == expected
146152
147153
In ``test_timedistance_v0``, we let pytest generate the test IDs.
148154

149-
In ``test_timedistance_v1``, we specified ``ids`` as a list of strings which were
155+
In ``test_timedistance_v1``, we let pytest generate the test IDs using argument
156+
name/value pairs.
157+
158+
In ``test_timedistance_v2``, we specified ``ids`` as a list of strings which were
150159
used as the test IDs. These are succinct, but can be a pain to maintain.
151160

152-
In ``test_timedistance_v2``, we specified ``ids`` as a function that can generate a
161+
In ``test_timedistance_v3``, we specified ``ids`` as a function that can generate a
153162
string representation to make part of the test ID. So our ``datetime`` values use the
154163
label generated by ``idfn``, but because we didn't generate a label for ``timedelta``
155164
objects, they are still using the default pytest representation:
@@ -160,22 +169,24 @@ objects, they are still using the default pytest representation:
160169
=========================== test session starts ============================
161170
platform linux -- Python 3.x.y, pytest-8.x.y, pluggy-1.x.y
162171
rootdir: /home/sweet/project
163-
collected 8 items
172+
collected 10 items
164173
165174
<Dir parametrize.rst-204>
166175
<Module test_time.py>
167176
<Function test_timedistance_v0[a0-b0-expected0]>
168177
<Function test_timedistance_v0[a1-b1-expected1]>
169-
<Function test_timedistance_v1[forward]>
170-
<Function test_timedistance_v1[backward]>
171-
<Function test_timedistance_v2[20011212-20011211-expected0]>
172-
<Function test_timedistance_v2[20011211-20011212-expected1]>
173-
<Function test_timedistance_v3[forward]>
174-
<Function test_timedistance_v3[backward]>
175-
176-
======================== 8 tests collected in 0.12s ========================
177-
178-
In ``test_timedistance_v3``, we used ``pytest.param`` to specify the test IDs
178+
<Function test_timedistance_v1[a=a0-b=b0-expected=expected0]>
179+
<Function test_timedistance_v1[a=a1-b=b1-expected=expected1]>
180+
<Function test_timedistance_v2[forward]>
181+
<Function test_timedistance_v2[backward]>
182+
<Function test_timedistance_v3[20011212-20011211-expected0]>
183+
<Function test_timedistance_v3[20011211-20011212-expected1]>
184+
<Function test_timedistance_v4[forward]>
185+
<Function test_timedistance_v4[backward]>
186+
187+
======================== 10 tests collected in 0.12s =======================
188+
189+
In ``test_timedistance_v4``, we used ``pytest.param`` to specify the test IDs
179190
together with the actual data, instead of listing them separately.
180191

181192
A quick port of "testscenarios"

Diff for: src/_pytest/mark/structures.py

+1
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,7 @@ def __call__( # type: ignore[override]
474474
| Callable[[Any], object | None]
475475
| None = ...,
476476
scope: _ScopeName | None = ...,
477+
id_names: bool = ...,
477478
) -> MarkDecorator: ...
478479

479480
class _UsefixturesMarkDecorator(MarkDecorator):

Diff for: src/_pytest/python.py

+28-7
Original file line numberDiff line numberDiff line change
@@ -884,18 +884,19 @@ class IdMaker:
884884
# Used only for clearer error messages.
885885
func_name: str | None
886886

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

922-
def _resolve_ids(self) -> Iterable[str]:
923+
def _resolve_ids(self, id_names: bool = False) -> Iterable[str]:
923924
"""Resolve IDs for all ParameterSets (may contain duplicates)."""
924925
for idx, parameterset in enumerate(self.parametersets):
925926
if parameterset.id is not None:
@@ -930,8 +931,9 @@ def _resolve_ids(self) -> Iterable[str]:
930931
yield self._idval_from_value_required(self.ids[idx], idx)
931932
else:
932933
# ID not provided - generate it.
934+
idval_func = self._idval_named if id_names else self._idval
933935
yield "-".join(
934-
self._idval(val, argname, idx)
936+
idval_func(val, argname, idx)
935937
for val, argname in zip(parameterset.values, self.argnames)
936938
)
937939

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

953+
def _idval_named(self, val: object, argname: str, idx: int) -> str:
954+
"""Make an ID in argname=value format for a parameter in a
955+
ParameterSet."""
956+
return "=".join((argname, self._idval(val, argname, idx)))
957+
951958
def _idval_from_function(self, val: object, argname: str, idx: int) -> str | None:
952959
"""Try to make an ID for a parameter in a ParameterSet using the
953960
user-provided id callable, if given."""
@@ -1141,6 +1148,7 @@ def parametrize(
11411148
indirect: bool | Sequence[str] = False,
11421149
ids: Iterable[object | None] | Callable[[Any], object | None] | None = None,
11431150
scope: _ScopeName | None = None,
1151+
id_names: bool = False,
11441152
*,
11451153
_param_mark: Mark | None = None,
11461154
) -> None:
@@ -1205,6 +1213,11 @@ def parametrize(
12051213
The scope is used for grouping tests by parameter instances.
12061214
It will also override any fixture-function defined scope, allowing
12071215
to set a dynamic scope using test context or configuration.
1216+
1217+
:param id_names:
1218+
Whether the argument names should be part of the auto-generated
1219+
ids. Defaults to ``False``. Must not be ``True`` if ``ids`` is
1220+
given.
12081221
"""
12091222
argnames, parametersets = ParameterSet._for_parametrize(
12101223
argnames,
@@ -1228,6 +1241,9 @@ def parametrize(
12281241
else:
12291242
scope_ = _find_parametrized_scope(argnames, self._arg2fixturedefs, indirect)
12301243

1244+
if id_names and ids is not None:
1245+
fail("'id_names' must not be combined with 'ids'", pytrace=False)
1246+
12311247
self._validate_if_using_arg_names(argnames, indirect)
12321248

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

12391255
ids = self._resolve_parameter_set_ids(
1240-
argnames, ids, parametersets, nodeid=self.definition.nodeid
1256+
argnames,
1257+
ids,
1258+
parametersets,
1259+
nodeid=self.definition.nodeid,
1260+
id_names=id_names,
12411261
)
12421262

12431263
# Store used (possibly generated) ids with parametrize Marks.
@@ -1322,6 +1342,7 @@ def _resolve_parameter_set_ids(
13221342
ids: Iterable[object | None] | Callable[[Any], object | None] | None,
13231343
parametersets: Sequence[ParameterSet],
13241344
nodeid: str,
1345+
id_names: bool,
13251346
) -> list[str]:
13261347
"""Resolve the actual ids for the given parameter sets.
13271348
@@ -1356,7 +1377,7 @@ def _resolve_parameter_set_ids(
13561377
nodeid=nodeid,
13571378
func_name=self.function.__name__,
13581379
)
1359-
return id_maker.make_unique_parameterset_ids()
1380+
return id_maker.make_unique_parameterset_ids(id_names=id_names)
13601381

13611382
def _validate_ids(
13621383
self,

Diff for: testing/python/metafunc.py

+26-4
Original file line numberDiff line numberDiff line change
@@ -199,18 +199,28 @@ def find_scope(argnames, indirect):
199199
)
200200
assert find_scope(["mixed_fix"], indirect=True) == Scope.Class
201201

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

206207
metafunc = self.Metafunc(func)
207208

208209
metafunc.parametrize("x", [1, 2], ids=["basic", "advanced"])
209-
metafunc.parametrize("y", ["abc", "def"])
210+
metafunc.parametrize("y", ["abc", "def"], id_names=id_names)
210211
ids = [x.id for x in metafunc._calls]
211-
assert ids == ["basic-abc", "basic-def", "advanced-abc", "advanced-def"]
212+
if id_names:
213+
assert ids == [
214+
"basic-y=abc",
215+
"basic-y=def",
216+
"advanced-y=abc",
217+
"advanced-y=def",
218+
]
219+
else:
220+
assert ids == ["basic-abc", "basic-def", "advanced-abc", "advanced-def"]
212221

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

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

234+
def test_parametrize_with_bad_ids_name_combination(self) -> None:
235+
def func(x):
236+
pass
237+
238+
metafunc = self.Metafunc(func)
239+
240+
with pytest.raises(
241+
fail.Exception,
242+
match="'id_names' must not be combined with 'ids'",
243+
):
244+
metafunc.parametrize("x", [1, 2], ids=["basic", "advanced"], id_names=True)
245+
224246
def test_parametrize_with_wrong_number_of_ids(self) -> None:
225247
def func(x, y):
226248
pass

0 commit comments

Comments
 (0)