Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow adding parameter names to auto-generated parametrized test IDs #13106

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Aviral Verma
Aviv Palivoda
Babak Keyvani
Barney Gale
Bastian Krause
Ben Brown
Ben Gartner
Ben Leith
Expand Down
1 change: 1 addition & 0 deletions changelog/13055.feature.rst
Original file line number Diff line number Diff line change
@@ -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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At first glance, it seems that there are two different functions supporting ids_name, and perhaps only @pytest.mark.parameterize() should be show. Additionally, it would be very enticing if you added an example in the changelog, such as: #12492.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since both the mark decorator and the method on Metafunc now support id_names, I'd like to keep it that way if the maintainers don't have anything against this.

Regarding an example in the changelog: I've added an example to the documentation. Shouldn't that be enough? At least to me the changelog entry pretty much covers the new feature.

43 changes: 27 additions & 16 deletions doc/en/example/parametrize.rst
Original file line number Diff line number Diff line change
Expand Up @@ -111,20 +111,26 @@ 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
return val.strftime("%Y%m%d")


@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

Expand All @@ -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:
Expand All @@ -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

<Dir parametrize.rst-204>
<Module test_time.py>
<Function test_timedistance_v0[a0-b0-expected0]>
<Function test_timedistance_v0[a1-b1-expected1]>
<Function test_timedistance_v1[forward]>
<Function test_timedistance_v1[backward]>
<Function test_timedistance_v2[20011212-20011211-expected0]>
<Function test_timedistance_v2[20011211-20011212-expected1]>
<Function test_timedistance_v3[forward]>
<Function test_timedistance_v3[backward]>

======================== 8 tests collected in 0.12s ========================

In ``test_timedistance_v3``, we used ``pytest.param`` to specify the test IDs
<Function test_timedistance_v1[a=a0-b=b0-expected=expected0]>
<Function test_timedistance_v1[a=a1-b=b1-expected=expected1]>
<Function test_timedistance_v2[forward]>
<Function test_timedistance_v2[backward]>
<Function test_timedistance_v3[20011212-20011211-expected0]>
<Function test_timedistance_v3[20011211-20011212-expected1]>
<Function test_timedistance_v4[forward]>
<Function test_timedistance_v4[backward]>

======================== 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"
Expand Down
1 change: 1 addition & 0 deletions src/_pytest/mark/structures.py
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ def __call__( # type: ignore[override]
| Callable[[Any], object | None]
| None = ...,
scope: _ScopeName | None = ...,
id_names: bool = ...,
) -> MarkDecorator: ...

class _UsefixturesMarkDecorator(MarkDecorator):
Expand Down
35 changes: 28 additions & 7 deletions src/_pytest/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -884,18 +884,19 @@
# 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 <prm_1_token>-...-<prm_n_token>[counter], where prm_x_token is
Format is [<prm_1>=]<prm_1_token>-...-[<prm_n>=]<prm_n_token>[counter],
where prm_x is <argname> (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 <argname><parameterset index>
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.
Expand All @@ -919,7 +920,7 @@
), 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:
Expand All @@ -930,8 +931,9 @@
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)
)

Expand All @@ -948,6 +950,11 @@
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)))

Check warning on line 956 in src/_pytest/python.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/python.py#L956

Added line #L956 was not covered by tests

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."""
Expand Down Expand Up @@ -1141,6 +1148,7 @@
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:
Expand Down Expand Up @@ -1205,6 +1213,11 @@
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,
Expand All @@ -1228,6 +1241,9 @@
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)

Check warning on line 1245 in src/_pytest/python.py

View check run for this annotation

Codecov / codecov/patch

src/_pytest/python.py#L1245

Added line #L1245 was not covered by tests

self._validate_if_using_arg_names(argnames, indirect)

# Use any already (possibly) generated ids with parametrize Marks.
Expand All @@ -1237,7 +1253,11 @@
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.
Expand Down Expand Up @@ -1322,6 +1342,7 @@
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.

Expand Down Expand Up @@ -1356,7 +1377,7 @@
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,
Expand Down
30 changes: 26 additions & 4 deletions testing/python/metafunc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down
Loading