diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index f2bf5320c..09196cfa9 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -70,7 +70,6 @@ if TYPE_CHECKING: from typing import Deque from _pytest.main import Session - from _pytest.python import CallSpec2 from _pytest.python import Function from _pytest.python import Metafunc @@ -243,34 +242,41 @@ def getfixturemarker(obj: object) -> Optional["FixtureFunctionMarker"]: _Key = Tuple[object, ...] -def get_parametrized_fixture_keys(item: nodes.Item, scope: Scope) -> Iterator[_Key]: - """Return list of keys for all parametrized arguments which match +def get_fixture_keys(item: nodes.Item, scope: Scope) -> Iterator[_Key]: + """Return list of keys for all arguments which match the specified scope.""" assert scope is not Scope.Function - try: - callspec = item.callspec # type: ignore[attr-defined] - except AttributeError: - pass - else: - cs: CallSpec2 = callspec - # cs.indices.items() is random order of argnames. Need to - # sort this so that different calls to - # get_parametrized_fixture_keys will be deterministic. - for argname, param_index in sorted(cs.indices.items()): - if cs._arg2scope[argname] != scope: + if hasattr(item, "_fixtureinfo"): + for argname in item._fixtureinfo.names_closure: + if argname not in item._fixtureinfo.name2fixturedefs: + # We can also raise FixtureLookupError continue - if scope is Scope.Session: - key: _Key = (argname, param_index) - elif scope is Scope.Package: - key = (argname, param_index, item.path) - elif scope is Scope.Module: - key = (argname, param_index, item.path) - elif scope is Scope.Class: - item_cls = item.cls # type: ignore[attr-defined] - key = (argname, param_index, item.path, item_cls) - else: - assert_never(scope) - yield key + is_parametrized = ( + hasattr(item, "callspec") and argname in item.callspec._arg2scope + ) + fixturedef = item._fixtureinfo.name2fixturedefs[argname][-1] + # In the case item is parametrized on the `argname` with + # a scope, it overrides that of the fixture. + if ( + is_parametrized + and cast(Function, item).callspec._arg2scope[argname] == scope + ) or (not is_parametrized and fixturedef._scope == scope): + param_index = None + if is_parametrized: + param_index = cast(Function, item).callspec.indices[argname] + + if scope is Scope.Session: + key: _Key = (argname, param_index) + elif scope is Scope.Package: + key = (argname, param_index, item.path.parent) + elif scope is Scope.Module: + key = (argname, param_index, item.path) + elif scope is Scope.Class: + item_cls = item.cls # type: ignore[attr-defined] + key = (argname, param_index, item.path, item_cls) + else: + assert_never(scope) + yield key # Algorithm for sorting on a per-parametrized resource setup basis. @@ -288,7 +294,7 @@ def reorder_items(items: Sequence[nodes.Item]) -> List[nodes.Item]: item_d: Dict[_Key, Deque[nodes.Item]] = defaultdict(deque) items_by_argkey[scope] = item_d for item in items: - keys = dict.fromkeys(get_parametrized_fixture_keys(item, scope), None) + keys = dict.fromkeys(get_fixture_keys(item, scope), None) if keys: d[item] = keys for key in keys: diff --git a/testing/python/fixtures.py b/testing/python/fixtures.py index 191689d1c..802dc4544 100644 --- a/testing/python/fixtures.py +++ b/testing/python/fixtures.py @@ -2731,16 +2731,19 @@ class TestFixtureMarker: """ ) result = pytester.runpytest("-v") + # Order changed because fixture keys were sorted by their names in fixtures::get_fixture_keys + # beforehand so encap key came before flavor. This isn't problematic here as both fixtures + # are session-scoped but when this isn't the case, it might be problematic. result.stdout.fnmatch_lines( """ test_dynamic_parametrized_ordering.py::test[flavor1-vxlan] PASSED test_dynamic_parametrized_ordering.py::test2[flavor1-vxlan] PASSED - test_dynamic_parametrized_ordering.py::test[flavor2-vxlan] PASSED - test_dynamic_parametrized_ordering.py::test2[flavor2-vxlan] PASSED - test_dynamic_parametrized_ordering.py::test[flavor2-vlan] PASSED - test_dynamic_parametrized_ordering.py::test2[flavor2-vlan] PASSED test_dynamic_parametrized_ordering.py::test[flavor1-vlan] PASSED test_dynamic_parametrized_ordering.py::test2[flavor1-vlan] PASSED + test_dynamic_parametrized_ordering.py::test[flavor2-vlan] PASSED + test_dynamic_parametrized_ordering.py::test2[flavor2-vlan] PASSED + test_dynamic_parametrized_ordering.py::test[flavor2-vxlan] PASSED + test_dynamic_parametrized_ordering.py::test2[flavor2-vxlan] PASSED """ ) @@ -4536,3 +4539,65 @@ def test_yield_fixture_with_no_value(pytester: Pytester) -> None: result.assert_outcomes(errors=1) result.stdout.fnmatch_lines([expected]) assert result.ret == ExitCode.TESTS_FAILED + + +def test_reorder_with_nonparametrized_fixtures(pytester: Pytester): + path = pytester.makepyfile( + """ + import pytest + + @pytest.fixture(scope='module') + def a(): + return "a" + + @pytest.fixture(scope='module') + def b(): + return "b" + + def test_0(a): + pass + + def test_1(b): + pass + + def test_2(a): + pass + + def test_3(b): + pass + + def test_4(b): + pass + """ + ) + result = pytester.runpytest(path, "-q", "--collect-only") + result.stdout.fnmatch_lines([f"*test_{i}*" for i in [0, 2, 1, 3, 4]]) + + +def test_reorder_with_both_parametrized_and_nonparametrized_fixtures( + pytester: Pytester, +): + path = pytester.makepyfile( + """ + import pytest + + @pytest.fixture(scope='module',params=[None]) + def parametrized(): + yield + + @pytest.fixture(scope='module') + def nonparametrized(): + yield + + def test_0(parametrized, nonparametrized): + pass + + def test_1(): + pass + + def test_2(nonparametrized): + pass + """ + ) + result = pytester.runpytest(path, "-q", "--collect-only") + result.stdout.fnmatch_lines([f"*test_{i}*" for i in [0, 2, 1]])