Skip to content

Commit 5a50852

Browse files
author
Tobias Deiminger
committed
Fix test reordering for indirect parameterization
reorder_items groups tests so that each group can share indirectly parameterized fixture instances. This optimizes for fewer fixture setup/teardown cycles. Prior to this commit, grouping considered an parameter's index, not the parameter value itself, to determine whether a parameter is "the same" and therefore can be shared. Relying on indexes is fast, however only works when there's exactly one parameter list for one fixture. If we provide multiple (possibly different) lists for the same fixture, e.g. by decorating test items, one index can no longer refer to "the same" parameter. In order to still be able to group for fixture reuse, we have to group by parameter value. Caution: The value ends up inside the key of another dict, and therefore must be hashable. This was true for indexes, but no longer is guaranteed for user provided values. A user may e.g. provide dicts or numpy arrays. The SafeHashWrapper ensures a fallback to id() in such a case. Fixes pytest-dev#8914.
1 parent e01231c commit 5a50852

File tree

5 files changed

+60
-6
lines changed

5 files changed

+60
-6
lines changed

AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,7 @@ Thomas Grainger
320320
Thomas Hisch
321321
Tim Hoffmann
322322
Tim Strazny
323+
Tobias Deiminger
323324
Tom Dalton
324325
Tom Viner
325326
Tomáš Gavenčiak

changelog/8914.bugfix.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
If fixtures had been indirectly parameterized via test function, e.g. using the
2+
``@pytest.mark.parametrize(indirect=True)``, reordering of tests for the least possible fixture setup/teardown cycles
3+
did not work. Optimized test groups can now be determined given the user provided parameter type is hashable.

src/_pytest/fixtures.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import warnings
66
from collections import defaultdict
77
from collections import deque
8+
from collections.abc import Hashable
89
from contextlib import suppress
910
from pathlib import Path
1011
from types import TracebackType
@@ -240,6 +241,24 @@ def getfixturemarker(obj: object) -> Optional["FixtureFunctionMarker"]:
240241
_Key = Tuple[object, ...]
241242

242243

244+
@attr.s(auto_attribs=True, eq=False)
245+
class SafeHashWrapper:
246+
obj: Any
247+
248+
def __eq__(self, other) -> Any:
249+
try:
250+
res = self.obj == other
251+
bool(res)
252+
return res
253+
except Exception:
254+
return id(self.obj) == id(other)
255+
256+
def __hash__(self) -> Any:
257+
if isinstance(self.obj, Hashable):
258+
return hash(self.obj)
259+
return hash(id(self.obj))
260+
261+
243262
def get_parametrized_fixture_keys(item: nodes.Item, scope: Scope) -> Iterator[_Key]:
244263
"""Return list of keys for all parametrized arguments which match
245264
the specified scope."""
@@ -256,15 +275,16 @@ def get_parametrized_fixture_keys(item: nodes.Item, scope: Scope) -> Iterator[_K
256275
for argname, param_index in sorted(cs.indices.items()):
257276
if cs._arg2scope[argname] != scope:
258277
continue
278+
param = SafeHashWrapper(cs.params.get(argname, param_index))
259279
if scope is Scope.Session:
260-
key: _Key = (argname, param_index)
280+
key: _Key = (argname, param)
261281
elif scope is Scope.Package:
262-
key = (argname, param_index, item.path.parent)
282+
key = (argname, param, item.path.parent)
263283
elif scope is Scope.Module:
264-
key = (argname, param_index, item.path)
284+
key = (argname, param, item.path)
265285
elif scope is Scope.Class:
266286
item_cls = item.cls # type: ignore[attr-defined]
267-
key = (argname, param_index, item.path, item_cls)
287+
key = (argname, param, item.path, item_cls)
268288
else:
269289
assert_never(scope)
270290
yield key

testing/example_scripts/issue_519.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,13 +22,13 @@ def checked_order():
2222
assert order == [
2323
("issue_519.py", "fix1", "arg1v1"),
2424
("test_one[arg1v1-arg2v1]", "fix2", "arg2v1"),
25-
("test_two[arg1v1-arg2v1]", "fix2", "arg2v1"),
2625
("test_one[arg1v1-arg2v2]", "fix2", "arg2v2"),
26+
("test_two[arg1v1-arg2v1]", "fix2", "arg2v1"),
2727
("test_two[arg1v1-arg2v2]", "fix2", "arg2v2"),
2828
("issue_519.py", "fix1", "arg1v2"),
2929
("test_one[arg1v2-arg2v1]", "fix2", "arg2v1"),
30-
("test_two[arg1v2-arg2v1]", "fix2", "arg2v1"),
3130
("test_one[arg1v2-arg2v2]", "fix2", "arg2v2"),
31+
("test_two[arg1v2-arg2v1]", "fix2", "arg2v1"),
3232
("test_two[arg1v2-arg2v2]", "fix2", "arg2v2"),
3333
]
3434

testing/python/fixtures.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1312,6 +1312,36 @@ def test2(no_eq):
13121312
result = pytester.runpytest()
13131313
result.stdout.fnmatch_lines(["*4 passed*"])
13141314

1315+
def test_reorder_indirect(self, pytester: Pytester) -> None:
1316+
pytester.makepyfile(
1317+
"""
1318+
import pytest
1319+
@pytest.fixture(scope="session")
1320+
def foo(request):
1321+
print(f'prepare foo-%s' % request.param)
1322+
yield request.param
1323+
print(f'teardown foo-%s' % request.param)
1324+
1325+
@pytest.mark.parametrize("foo", ["data1"], indirect=True)
1326+
def test1(foo):
1327+
pass
1328+
1329+
@pytest.mark.parametrize("foo", ["data2"], indirect=True)
1330+
def test2(foo):
1331+
pass
1332+
1333+
@pytest.mark.parametrize("foo", ["data1"], indirect=True)
1334+
def test3(foo):
1335+
pass
1336+
"""
1337+
)
1338+
result = pytester.runpytest("-s")
1339+
output = result.stdout.str()
1340+
assert output.count("prepare foo-data1") == 1
1341+
assert output.count("prepare foo-data2") == 1
1342+
assert output.count("teardown foo-data1") == 1
1343+
assert output.count("teardown foo-data2") == 1
1344+
13151345
def test_funcarg_parametrized_and_used_twice(self, pytester: Pytester) -> None:
13161346
pytester.makepyfile(
13171347
"""

0 commit comments

Comments
 (0)