Skip to content

Commit 4b880f7

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 d149ab3 commit 4b880f7

File tree

6 files changed

+84
-27
lines changed

6 files changed

+84
-27
lines changed

AUTHORS

+1
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ Aviral Verma
5959
Aviv Palivoda
6060
Babak Keyvani
6161
Barney Gale
62+
Bastian Krause
6263
Ben Brown
6364
Ben Gartner
6465
Ben Leith

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.

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-206>
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"

src/_pytest/mark/structures.py

+1
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,7 @@ def __call__( # type: ignore[override]
492492
| Callable[[Any], object | None]
493493
| None = ...,
494494
scope: _ScopeName | None = ...,
495+
id_names: bool = ...,
495496
) -> MarkDecorator: ...
496497

497498
class _UsefixturesMarkDecorator(MarkDecorator):

src/_pytest/python.py

+28-7
Original file line numberDiff line numberDiff line change
@@ -887,18 +887,19 @@ class IdMaker:
887887
# Used only for clearer error messages.
888888
func_name: str | None
889889

890-
def make_unique_parameterset_ids(self) -> list[str | _HiddenParam]:
890+
def make_unique_parameterset_ids(self, id_names: bool = False) -> list[str | _HiddenParam]:
891891
"""Make a unique identifier for each ParameterSet, that may be used to
892892
identify the parametrization in a node ID.
893893
894-
Format is <prm_1_token>-...-<prm_n_token>[counter], where prm_x_token is
894+
Format is [<prm_1>=]<prm_1_token>-...-[<prm_n>=]<prm_n_token>[counter],
895+
where prm_x is <argname> (only for id_names=True) and prm_x_token is
895896
- user-provided id, if given
896897
- else an id derived from the value, applicable for certain types
897898
- else <argname><parameterset index>
898899
The counter suffix is appended only in case a string wouldn't be unique
899900
otherwise.
900901
"""
901-
resolved_ids = list(self._resolve_ids())
902+
resolved_ids = list(self._resolve_ids(id_names=id_names))
902903
# All IDs must be unique!
903904
if len(resolved_ids) != len(set(resolved_ids)):
904905
# Record the number of occurrences of each ID.
@@ -924,7 +925,7 @@ def make_unique_parameterset_ids(self) -> list[str | _HiddenParam]:
924925
)
925926
return resolved_ids
926927

927-
def _resolve_ids(self) -> Iterable[str | _HiddenParam]:
928+
def _resolve_ids(self, id_names: bool = False) -> Iterable[str | _HiddenParam]:
928929
"""Resolve IDs for all ParameterSets (may contain duplicates)."""
929930
for idx, parameterset in enumerate(self.parametersets):
930931
if parameterset.id is not None:
@@ -941,8 +942,9 @@ def _resolve_ids(self) -> Iterable[str | _HiddenParam]:
941942
yield self._idval_from_value_required(self.ids[idx], idx)
942943
else:
943944
# ID not provided - generate it.
945+
idval_func = self._idval_named if id_names else self._idval
944946
yield "-".join(
945-
self._idval(val, argname, idx)
947+
idval_func(val, argname, idx)
946948
for val, argname in zip(parameterset.values, self.argnames)
947949
)
948950

@@ -959,6 +961,11 @@ def _idval(self, val: object, argname: str, idx: int) -> str:
959961
return idval
960962
return self._idval_from_argname(argname, idx)
961963

964+
def _idval_named(self, val: object, argname: str, idx: int) -> str:
965+
"""Make an ID in argname=value format for a parameter in a
966+
ParameterSet."""
967+
return "=".join((argname, self._idval(val, argname, idx)))
968+
962969
def _idval_from_function(self, val: object, argname: str, idx: int) -> str | None:
963970
"""Try to make an ID for a parameter in a ParameterSet using the
964971
user-provided id callable, if given."""
@@ -1162,6 +1169,7 @@ def parametrize(
11621169
indirect: bool | Sequence[str] = False,
11631170
ids: Iterable[object | None] | Callable[[Any], object | None] | None = None,
11641171
scope: _ScopeName | None = None,
1172+
id_names: bool = False,
11651173
*,
11661174
_param_mark: Mark | None = None,
11671175
) -> None:
@@ -1231,6 +1239,11 @@ def parametrize(
12311239
The scope is used for grouping tests by parameter instances.
12321240
It will also override any fixture-function defined scope, allowing
12331241
to set a dynamic scope using test context or configuration.
1242+
1243+
:param id_names:
1244+
Whether the argument names should be part of the auto-generated
1245+
ids. Defaults to ``False``. Must not be ``True`` if ``ids`` is
1246+
given.
12341247
"""
12351248
argnames, parametersets = ParameterSet._for_parametrize(
12361249
argnames,
@@ -1254,6 +1267,9 @@ def parametrize(
12541267
else:
12551268
scope_ = _find_parametrized_scope(argnames, self._arg2fixturedefs, indirect)
12561269

1270+
if id_names and ids is not None:
1271+
fail("'id_names' must not be combined with 'ids'", pytrace=False)
1272+
12571273
self._validate_if_using_arg_names(argnames, indirect)
12581274

12591275
# Use any already (possibly) generated ids with parametrize Marks.
@@ -1263,7 +1279,11 @@ def parametrize(
12631279
ids = generated_ids
12641280

12651281
ids = self._resolve_parameter_set_ids(
1266-
argnames, ids, parametersets, nodeid=self.definition.nodeid
1282+
argnames,
1283+
ids,
1284+
parametersets,
1285+
nodeid=self.definition.nodeid,
1286+
id_names=id_names,
12671287
)
12681288

12691289
# Store used (possibly generated) ids with parametrize Marks.
@@ -1348,6 +1368,7 @@ def _resolve_parameter_set_ids(
13481368
ids: Iterable[object | None] | Callable[[Any], object | None] | None,
13491369
parametersets: Sequence[ParameterSet],
13501370
nodeid: str,
1371+
id_names: bool,
13511372
) -> list[str | _HiddenParam]:
13521373
"""Resolve the actual ids for the given parameter sets.
13531374
@@ -1382,7 +1403,7 @@ def _resolve_parameter_set_ids(
13821403
nodeid=nodeid,
13831404
func_name=self.function.__name__,
13841405
)
1385-
return id_maker.make_unique_parameterset_ids()
1406+
return id_maker.make_unique_parameterset_ids(id_names=id_names)
13861407

13871408
def _validate_ids(
13881409
self,

testing/python/metafunc.py

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

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

207208
metafunc = self.Metafunc(func)
208209

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

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

217227
def func(x):
@@ -222,6 +232,18 @@ def func(x):
222232
ids = [x.id for x in metafunc._calls]
223233
assert ids == ["basic", "advanced"]
224234

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

0 commit comments

Comments
 (0)