diff --git a/src/_pytest/main.py b/src/_pytest/main.py index c4df4e46983..450966c45cf 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -893,6 +893,23 @@ def _collect_one_node( return rep, True else: rep = collect_one_node(node) + if rep.passed and node in self._collection_cache: + # Re-collection (handle_dupes=False) creates fresh child nodes. + # Reuse previously-seen Directory children so that fixture + # registration (keyed by node identity) remains valid. (#14635) + prev_result = self._collection_cache[node].result + prev_dirs = { + child.path: child + for child in prev_result + if isinstance(child, nodes.Directory) + } + if prev_dirs: + rep.result = [ + prev_dirs.get(child.path, child) + if isinstance(child, nodes.Directory) + else child + for child in rep.result + ] self._collection_cache[node] = rep return rep, False diff --git a/testing/test_conftest.py b/testing/test_conftest.py index 23a0db81c39..38363a3ba1c 100644 --- a/testing/test_conftest.py +++ b/testing/test_conftest.py @@ -911,6 +911,115 @@ def test_uses_ancestor(ancestor_fixture): result.stdout.fnmatch_lines(["*test_uses_ancestor*PASSED*", "*1 passed*"]) +def test_fixture_closure_order_independence_with_parametrize( + pytester: Pytester, +) -> None: + """Regression test for #14635. + + A test's fixture closure (and thus parametrize validation) should be + independent of which unrelated paths were collected earlier in the session. + + The scenario: a test uses @pytest.mark.parametrize("fixture_param", [...]) + where fixture_param is NOT a direct arg of the test but IS an argname of a + fixture the test depends on transitively. Collecting unrelated directories + before the test's directory should not cause the fixture_param to drop out + of the closure. + """ + root = pytester.path + tests = root / "tests" + tests.mkdir() + tests.joinpath("__init__.py").write_text("", encoding="utf-8") + + # tests/conftest.py - empty (or with some unrelated fixture) + tests.joinpath("conftest.py").write_text( + textwrap.dedent("""\ + import pytest + """), + encoding="utf-8", + ) + + # tests/components/ with its conftest + components = tests / "components" + components.mkdir() + components.joinpath("__init__.py").write_text("", encoding="utf-8") + components.joinpath("conftest.py").write_text( + textwrap.dedent("""\ + import pytest + + @pytest.fixture + def cache_dir_side_effect(): + return None + + @pytest.fixture + def mock_init_cache_dir(cache_dir_side_effect): + return cache_dir_side_effect + + @pytest.fixture + def mock_cache_dir(mock_init_cache_dir): + return mock_init_cache_dir + """), + encoding="utf-8", + ) + + # tests/components/water_heater/ - unrelated test directory + water_heater = components / "water_heater" + water_heater.mkdir() + water_heater.joinpath("__init__.py").write_text("", encoding="utf-8") + water_heater.joinpath("test_water_heater.py").write_text( + textwrap.dedent("""\ + def test_water_heater(): + pass + """), + encoding="utf-8", + ) + + # tests/components/tts/ - the problematic test directory + tts = components / "tts" + tts.mkdir() + tts.joinpath("__init__.py").write_text("", encoding="utf-8") + tts.joinpath("conftest.py").write_text( + textwrap.dedent("""\ + import pytest + + @pytest.fixture(autouse=True) + def mock_cache_dir(mock_cache_dir): + # Autouse override that requests the parent fixture of same name + return mock_cache_dir + """), + encoding="utf-8", + ) + tts.joinpath("test_init.py").write_text( + textwrap.dedent("""\ + import pytest + + @pytest.mark.parametrize("cache_dir_side_effect", ["error_value"]) + async def test_setup_no_access(mock_init_cache_dir): + assert mock_init_cache_dir == "error_value" + """), + encoding="utf-8", + ) + + # tests/test_config_entries.py - another unrelated test + tests.joinpath("test_config_entries.py").write_text( + textwrap.dedent("""\ + def test_config(): + pass + """), + encoding="utf-8", + ) + + # This order triggers the bug: collecting water_heater and config_entries + # BEFORE tts causes the fixture closure to be wrong. + result = pytester.runpytest( + "--collect-only", + str(water_heater), + str(tests / "test_config_entries.py"), + str(tts / "test_init.py"), + ) + result.stdout.fnmatch_lines(["*test_setup_no_access*"]) + assert result.ret == ExitCode.OK + + def test_required_option_help(pytester: Pytester) -> None: pytester.makeconftest("assert 0") x = pytester.mkdir("x")