Skip to content

Commit 7891bf7

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 7891bf7

File tree

6 files changed

+84
-27
lines changed

6 files changed

+84
-27
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: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -888,18 +888,19 @@ 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(self, id_names: bool = False) -> list[str | _HiddenParam]:
892892
"""Make a unique identifier for each ParameterSet, that may be used to
893893
identify the parametrization in a node ID.
894894
895-
Format is <prm_1_token>-...-<prm_n_token>[counter], where prm_x_token is
895+
Format is [<prm_1>=]<prm_1_token>-...-[<prm_n>=]<prm_n_token>[counter],
896+
where prm_x is <argname> (only for id_names=True) and prm_x_token is
896897
- user-provided id, if given
897898
- else an id derived from the value, applicable for certain types
898899
- else <argname><parameterset index>
899900
The counter suffix is appended only in case a string wouldn't be unique
900901
otherwise.
901902
"""
902-
resolved_ids = list(self._resolve_ids())
903+
resolved_ids = list(self._resolve_ids(id_names=id_names))
903904
# All IDs must be unique!
904905
if len(resolved_ids) != len(set(resolved_ids)):
905906
# Record the number of occurrences of each ID.
@@ -925,7 +926,7 @@ def make_unique_parameterset_ids(self) -> list[str | _HiddenParam]:
925926
)
926927
return resolved_ids
927928

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

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

965+
def _idval_named(self, val: object, argname: str, idx: int) -> str:
966+
"""Make an ID in argname=value format for a parameter in a
967+
ParameterSet."""
968+
return "=".join((argname, self._idval(val, argname, idx)))
969+
963970
def _idval_from_function(self, val: object, argname: str, idx: int) -> str | None:
964971
"""Try to make an ID for a parameter in a ParameterSet using the
965972
user-provided id callable, if given."""
@@ -1167,6 +1174,7 @@ def parametrize(
11671174
indirect: bool | Sequence[str] = False,
11681175
ids: Iterable[object | None] | Callable[[Any], object | None] | None = None,
11691176
scope: _ScopeName | None = None,
1177+
id_names: bool = False,
11701178
*,
11711179
_param_mark: Mark | None = None,
11721180
) -> None:
@@ -1236,6 +1244,11 @@ def parametrize(
12361244
The scope is used for grouping tests by parameter instances.
12371245
It will also override any fixture-function defined scope, allowing
12381246
to set a dynamic scope using test context or configuration.
1247+
1248+
:param id_names:
1249+
Whether the argument names should be part of the auto-generated
1250+
ids. Defaults to ``False``. Must not be ``True`` if ``ids`` is
1251+
given.
12391252
"""
12401253
nodeid = self.definition.nodeid
12411254

@@ -1261,6 +1274,9 @@ def parametrize(
12611274
else:
12621275
scope_ = _find_parametrized_scope(argnames, self._arg2fixturedefs, indirect)
12631276

1277+
if id_names and ids is not None:
1278+
fail("'id_names' must not be combined with 'ids'", pytrace=False)
1279+
12641280
self._validate_if_using_arg_names(argnames, indirect)
12651281

12661282
# Use any already (possibly) generated ids with parametrize Marks.
@@ -1270,7 +1286,11 @@ def parametrize(
12701286
ids = generated_ids
12711287

12721288
ids = self._resolve_parameter_set_ids(
1273-
argnames, ids, parametersets, nodeid=self.definition.nodeid
1289+
argnames,
1290+
ids,
1291+
parametersets,
1292+
nodeid=self.definition.nodeid,
1293+
id_names=id_names,
12741294
)
12751295

12761296
# Store used (possibly generated) ids with parametrize Marks.
@@ -1356,6 +1376,7 @@ def _resolve_parameter_set_ids(
13561376
ids: Iterable[object | None] | Callable[[Any], object | None] | None,
13571377
parametersets: Sequence[ParameterSet],
13581378
nodeid: str,
1379+
id_names: bool,
13591380
) -> list[str | _HiddenParam]:
13601381
"""Resolve the actual ids for the given parameter sets.
13611382
@@ -1390,7 +1411,7 @@ def _resolve_parameter_set_ids(
13901411
nodeid=nodeid,
13911412
func_name=self.function.__name__,
13921413
)
1393-
return id_maker.make_unique_parameterset_ids()
1414+
return id_maker.make_unique_parameterset_ids(id_names=id_names)
13941415

13951416
def _validate_ids(
13961417
self,

testing/python/metafunc.py

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -204,18 +204,28 @@ 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):
209210
pass
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):
@@ -226,6 +236,18 @@ def func(x):
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+
pass
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)