From 441d49fc702cc424c9fdb2cc4740273cd35f962e Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 22 Jun 2026 13:24:33 +0200 Subject: [PATCH] fix: deduplicate Directory nodes on re-collection to preserve fixture identity (#14635) When Session re-collects a parent Directory (handle_dupes=False for file CLI args), fresh child nodes were created. Since fixture registration uses node identity for matching, the new Directory children didn't match fixtures registered with the original instances. Reuse previously-seen Directory children (by path) when re-collecting a parent. Module/File nodes are still recreated to preserve --keep-duplicates semantics. Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 --- src/_pytest/main.py | 17 ++++++ testing/test_conftest.py | 109 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) 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")