Skip to content

Commit a933cf5

Browse files
Merge pull request #14645 from RonnyPfannschmidt/fix/14635-deduplicate-directory-nodes-on-recollection
fix: deduplicate Directory nodes on re-collection to preserve fixture identity (#14635)
2 parents 35f21fd + b054cac commit a933cf5

3 files changed

Lines changed: 134 additions & 0 deletions

File tree

‎changelog/14635.bugfix.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

‎src/_pytest/main.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -893,6 +893,30 @@ def _collect_one_node(
893893
return rep, True
894894
else:
895895
rep = collect_one_node(node)
896+
if rep.passed and node in self._collection_cache:
897+
# Re-collection (handle_dupes=False) creates fresh child nodes.
898+
# Reuse previously-seen Directory children so that fixture
899+
# registration (keyed by node identity) remains valid. (#14635)
900+
#
901+
# This post-processing reconciliation is intentional: the
902+
# collection architecture is not ready for a session-global
903+
# Directory node registry, and pushing deduplication into
904+
# individual collectors (Dir.collect / Package.collect) would
905+
# leak session-level concerns into node code and miss
906+
# third-party Directory subclasses.
907+
prev_result = self._collection_cache[node].result
908+
prev_dirs = {
909+
child.path: child
910+
for child in prev_result
911+
if isinstance(child, nodes.Directory)
912+
}
913+
if prev_dirs:
914+
rep.result = [
915+
prev_dirs.get(child.path, child)
916+
if isinstance(child, nodes.Directory)
917+
else child
918+
for child in rep.result
919+
]
896920
self._collection_cache[node] = rep
897921
return rep, False
898922

‎testing/test_conftest.py‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,115 @@ def test_uses_ancestor(ancestor_fixture):
911911
result.stdout.fnmatch_lines(["*test_uses_ancestor*PASSED*", "*1 passed*"])
912912

913913

914+
def test_fixture_closure_order_independence_with_parametrize(
915+
pytester: Pytester,
916+
) -> None:
917+
"""Regression test for #14635.
918+
919+
A test's fixture closure (and thus parametrize validation) should be
920+
independent of which unrelated paths were collected earlier in the session.
921+
922+
The scenario: a test uses @pytest.mark.parametrize("fixture_param", [...])
923+
where fixture_param is NOT a direct arg of the test but IS an argname of a
924+
fixture the test depends on transitively. Collecting unrelated directories
925+
before the test's directory should not cause the fixture_param to drop out
926+
of the closure.
927+
"""
928+
root = pytester.path
929+
tests = root / "tests"
930+
tests.mkdir()
931+
tests.joinpath("__init__.py").write_text("", encoding="utf-8")
932+
933+
# tests/conftest.py - empty (or with some unrelated fixture)
934+
tests.joinpath("conftest.py").write_text(
935+
textwrap.dedent("""\
936+
import pytest
937+
"""),
938+
encoding="utf-8",
939+
)
940+
941+
# tests/components/ with its conftest
942+
components = tests / "components"
943+
components.mkdir()
944+
components.joinpath("__init__.py").write_text("", encoding="utf-8")
945+
components.joinpath("conftest.py").write_text(
946+
textwrap.dedent("""\
947+
import pytest
948+
949+
@pytest.fixture
950+
def cache_dir_side_effect():
951+
return None
952+
953+
@pytest.fixture
954+
def mock_init_cache_dir(cache_dir_side_effect):
955+
return cache_dir_side_effect
956+
957+
@pytest.fixture
958+
def mock_cache_dir(mock_init_cache_dir):
959+
return mock_init_cache_dir
960+
"""),
961+
encoding="utf-8",
962+
)
963+
964+
# tests/components/water_heater/ - unrelated test directory
965+
water_heater = components / "water_heater"
966+
water_heater.mkdir()
967+
water_heater.joinpath("__init__.py").write_text("", encoding="utf-8")
968+
water_heater.joinpath("test_water_heater.py").write_text(
969+
textwrap.dedent("""\
970+
def test_water_heater():
971+
pass
972+
"""),
973+
encoding="utf-8",
974+
)
975+
976+
# tests/components/tts/ - the problematic test directory
977+
tts = components / "tts"
978+
tts.mkdir()
979+
tts.joinpath("__init__.py").write_text("", encoding="utf-8")
980+
tts.joinpath("conftest.py").write_text(
981+
textwrap.dedent("""\
982+
import pytest
983+
984+
@pytest.fixture(autouse=True)
985+
def mock_cache_dir(mock_cache_dir):
986+
# Autouse override that requests the parent fixture of same name
987+
return mock_cache_dir
988+
"""),
989+
encoding="utf-8",
990+
)
991+
tts.joinpath("test_init.py").write_text(
992+
textwrap.dedent("""\
993+
import pytest
994+
995+
@pytest.mark.parametrize("cache_dir_side_effect", ["error_value"])
996+
async def test_setup_no_access(mock_init_cache_dir):
997+
assert mock_init_cache_dir == "error_value"
998+
"""),
999+
encoding="utf-8",
1000+
)
1001+
1002+
# tests/test_config_entries.py - another unrelated test
1003+
tests.joinpath("test_config_entries.py").write_text(
1004+
textwrap.dedent("""\
1005+
def test_config():
1006+
pass
1007+
"""),
1008+
encoding="utf-8",
1009+
)
1010+
1011+
# This order triggers the bug: collecting water_heater and config_entries
1012+
# BEFORE tts causes the fixture closure to be wrong.
1013+
result = pytester.runpytest(
1014+
"--collect-only",
1015+
str(water_heater),
1016+
str(tests / "test_config_entries.py"),
1017+
str(tts / "test_init.py"),
1018+
)
1019+
result.stdout.fnmatch_lines(["*test_setup_no_access*"])
1020+
assert result.ret == ExitCode.OK
1021+
1022+
9141023
def test_required_option_help(pytester: Pytester) -> None:
9151024
pytester.makeconftest("assert 0")
9161025
x = pytester.mkdir("x")

0 commit comments

Comments
 (0)