Skip to content

Commit 1f94e78

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 8c5fc57 commit 1f94e78

File tree

6 files changed

+88
-29
lines changed

6 files changed

+88
-29
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Aviv Palivoda
6060
Babak Keyvani
6161
Bahram Farahmand
6262
Barney Gale
63+
Bastian Krause
6364
Ben Brown
6465
Ben Gartner
6566
Ben Leith

changelog/13055.feature.rst

Lines changed: 1 addition & 0 deletions
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

Lines changed: 27 additions & 16 deletions
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-208>
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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,7 @@ def __call__( # type: ignore[override]
519519
| Callable[[Any], object | None]
520520
| None = ...,
521521
scope: _ScopeName | None = ...,
522+
id_names: bool = ...,
522523
) -> MarkDecorator: ...
523524

524525
class _UsefixturesMarkDecorator(MarkDecorator):

src/_pytest/python.py

Lines changed: 30 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -888,18 +888,21 @@ class IdMaker:
888888
# Used only for clearer error messages.
889889
func_name: str | None
890890

891-
def make_unique_parameterset_ids(self) -> list[str | _HiddenParam]:
891+
def make_unique_parameterset_ids(
892+
self, id_names: bool = False
893+
) -> list[str | _HiddenParam]:
892894
"""Make a unique identifier for each ParameterSet, that may be used to
893895
identify the parametrization in a node ID.
894896
895-
Format is <prm_1_token>-...-<prm_n_token>[counter], where prm_x_token is
897+
Format is [<prm_1>=]<prm_1_token>-...-[<prm_n>=]<prm_n_token>[counter],
898+
where prm_x is <argname> (only for id_names=True) and prm_x_token is
896899
- user-provided id, if given
897900
- else an id derived from the value, applicable for certain types
898901
- else <argname><parameterset index>
899902
The counter suffix is appended only in case a string wouldn't be unique
900903
otherwise.
901904
"""
902-
resolved_ids = list(self._resolve_ids())
905+
resolved_ids = list(self._resolve_ids(id_names=id_names))
903906
# All IDs must be unique!
904907
if len(resolved_ids) != len(set(resolved_ids)):
905908
# Record the number of occurrences of each ID.
@@ -925,7 +928,7 @@ def make_unique_parameterset_ids(self) -> list[str | _HiddenParam]:
925928
)
926929
return resolved_ids
927930

928-
def _resolve_ids(self) -> Iterable[str | _HiddenParam]:
931+
def _resolve_ids(self, id_names: bool = False) -> Iterable[str | _HiddenParam]:
929932
"""Resolve IDs for all ParameterSets (may contain duplicates)."""
930933
for idx, parameterset in enumerate(self.parametersets):
931934
if parameterset.id is not None:
@@ -942,8 +945,9 @@ def _resolve_ids(self) -> Iterable[str | _HiddenParam]:
942945
yield self._idval_from_value_required(self.ids[idx], idx)
943946
else:
944947
# ID not provided - generate it.
948+
idval_func = self._idval_named if id_names else self._idval
945949
yield "-".join(
946-
self._idval(val, argname, idx)
950+
idval_func(val, argname, idx)
947951
for val, argname in zip(parameterset.values, self.argnames)
948952
)
949953

@@ -960,6 +964,11 @@ def _idval(self, val: object, argname: str, idx: int) -> str:
960964
return idval
961965
return self._idval_from_argname(argname, idx)
962966

967+
def _idval_named(self, val: object, argname: str, idx: int) -> str:
968+
"""Make an ID in argname=value format for a parameter in a
969+
ParameterSet."""
970+
return "=".join((argname, self._idval(val, argname, idx)))
971+
963972
def _idval_from_function(self, val: object, argname: str, idx: int) -> str | None:
964973
"""Try to make an ID for a parameter in a ParameterSet using the
965974
user-provided id callable, if given."""
@@ -1167,6 +1176,7 @@ def parametrize(
11671176
indirect: bool | Sequence[str] = False,
11681177
ids: Iterable[object | None] | Callable[[Any], object | None] | None = None,
11691178
scope: _ScopeName | None = None,
1179+
id_names: bool = False,
11701180
*,
11711181
_param_mark: Mark | None = None,
11721182
) -> None:
@@ -1236,6 +1246,11 @@ def parametrize(
12361246
The scope is used for grouping tests by parameter instances.
12371247
It will also override any fixture-function defined scope, allowing
12381248
to set a dynamic scope using test context or configuration.
1249+
1250+
:param id_names:
1251+
Whether the argument names should be part of the auto-generated
1252+
ids. Defaults to ``False``. Must not be ``True`` if ``ids`` is
1253+
given.
12391254
"""
12401255
nodeid = self.definition.nodeid
12411256

@@ -1261,6 +1276,9 @@ def parametrize(
12611276
else:
12621277
scope_ = _find_parametrized_scope(argnames, self._arg2fixturedefs, indirect)
12631278

1279+
if id_names and ids is not None:
1280+
fail("'id_names' must not be combined with 'ids'", pytrace=False)
1281+
12641282
self._validate_if_using_arg_names(argnames, indirect)
12651283

12661284
# Use any already (possibly) generated ids with parametrize Marks.
@@ -1270,7 +1288,11 @@ def parametrize(
12701288
ids = generated_ids
12711289

12721290
ids = self._resolve_parameter_set_ids(
1273-
argnames, ids, parametersets, nodeid=self.definition.nodeid
1291+
argnames,
1292+
ids,
1293+
parametersets,
1294+
nodeid=self.definition.nodeid,
1295+
id_names=id_names,
12741296
)
12751297

12761298
# Store used (possibly generated) ids with parametrize Marks.
@@ -1356,6 +1378,7 @@ def _resolve_parameter_set_ids(
13561378
ids: Iterable[object | None] | Callable[[Any], object | None] | None,
13571379
parametersets: Sequence[ParameterSet],
13581380
nodeid: str,
1381+
id_names: bool,
13591382
) -> list[str | _HiddenParam]:
13601383
"""Resolve the actual ids for the given parameter sets.
13611384
@@ -1390,7 +1413,7 @@ def _resolve_parameter_set_ids(
13901413
nodeid=nodeid,
13911414
func_name=self.function.__name__,
13921415
)
1393-
return id_maker.make_unique_parameterset_ids()
1416+
return id_maker.make_unique_parameterset_ids(id_names=id_names)
13941417

13951418
def _validate_ids(
13961419
self,

testing/python/metafunc.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -204,28 +204,50 @@ def find_scope(argnames, indirect):
204204
)
205205
assert find_scope(["mixed_fix"], indirect=True) == Scope.Class
206206

207-
def test_parametrize_and_id(self) -> None:
207+
@pytest.mark.parametrize("id_names", (False, True))
208+
def test_parametrize_and_id(self, id_names: bool) -> None:
208209
def func(x, y):
209-
pass
210+
"""Dummy function"""
210211

211212
metafunc = self.Metafunc(func)
212213

213214
metafunc.parametrize("x", [1, 2], ids=["basic", "advanced"])
214-
metafunc.parametrize("y", ["abc", "def"])
215+
metafunc.parametrize("y", ["abc", "def"], id_names=id_names)
215216
ids = [x.id for x in metafunc._calls]
216-
assert ids == ["basic-abc", "basic-def", "advanced-abc", "advanced-def"]
217+
if id_names:
218+
assert ids == [
219+
"basic-y=abc",
220+
"basic-y=def",
221+
"advanced-y=abc",
222+
"advanced-y=def",
223+
]
224+
else:
225+
assert ids == ["basic-abc", "basic-def", "advanced-abc", "advanced-def"]
217226

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

221231
def func(x):
222-
pass
232+
"""Dummy function"""
223233

224234
metafunc = self.Metafunc(func)
225235
metafunc.parametrize("x", [1, 2], ids=["basic", "advanced"])
226236
ids = [x.id for x in metafunc._calls]
227237
assert ids == ["basic", "advanced"]
228238

239+
def test_parametrize_with_bad_ids_name_combination(self) -> None:
240+
def func(x):
241+
"""Dummy function"""
242+
243+
metafunc = self.Metafunc(func)
244+
245+
with pytest.raises(
246+
fail.Exception,
247+
match="'id_names' must not be combined with 'ids'",
248+
):
249+
metafunc.parametrize("x", [1, 2], ids=["basic", "advanced"], id_names=True)
250+
229251
def test_parametrize_with_wrong_number_of_ids(self) -> None:
230252
def func(x, y):
231253
pass

0 commit comments

Comments
 (0)