From ecdc8d52efd5fe27e17e1e91eca3a27820c25071 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Mon, 22 Jun 2026 13:24:33 +0200 Subject: [PATCH 1/2] 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") From db001657fe765ef2d4c844bf2dec9e18fca3bc48 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Tue, 14 Jul 2026 10:09:31 +0200 Subject: [PATCH 2/2] docs: add changelog fragment and architectural comment for #14635 Add a changelog entry explaining the fix for fixture resolution failures when multiple CLI paths share ancestor directories, and a comment on the dedup code explaining why post-processing was chosen over a session-global deduplication mechanism. Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 Co-authored-by: Cursor --- changelog/14635.bugfix.rst | 1 + src/_pytest/main.py | 7 +++++++ 2 files changed, 8 insertions(+) create mode 100644 changelog/14635.bugfix.rst diff --git a/changelog/14635.bugfix.rst b/changelog/14635.bugfix.rst new file mode 100644 index 00000000000..bb349f6875f --- /dev/null +++ b/changelog/14635.bugfix.rst @@ -0,0 +1 @@ +Fixed fixture resolution failures when specifying multiple test paths on the command line that share common parent directories. Parametrized fixtures defined in a ``conftest.py`` could be silently dropped depending on the order of the specified paths. diff --git a/src/_pytest/main.py b/src/_pytest/main.py index 450966c45cf..1b337e20c7e 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -897,6 +897,13 @@ def _collect_one_node( # 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) + # + # This post-processing reconciliation is intentional: the + # collection architecture is not ready for a session-global + # Directory node registry, and pushing deduplication into + # individual collectors (Dir.collect / Package.collect) would + # leak session-level concerns into node code and miss + # third-party Directory subclasses. prev_result = self._collection_cache[node].result prev_dirs = { child.path: child