From 0526d9baa669ed37dea2a3fbe8f4f9974d3e3b3e Mon Sep 17 00:00:00 2001 From: minbang930 Date: Mon, 1 Jun 2026 12:02:24 +0900 Subject: [PATCH] Fix module fixtures with --doctest-modules pytest can collect the same Python module through DoctestModule and Module. FixtureManager skipped the second parse because it keyed parsed holders only by module object. Key parsed holders by visibility anchor too. Normal tests get FixtureDefs anchored to their Module collector, and node-based fixture scoping remains unchanged. Closes #14533. Co-authored-by: OpenAI Codex --- AUTHORS | 1 + changelog/14533.bugfix.rst | 1 + src/_pytest/fixtures.py | 4 --- testing/test_doctest.py | 64 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 4 deletions(-) create mode 100644 changelog/14533.bugfix.rst diff --git a/AUTHORS b/AUTHORS index 27c0b3ac408..972f39aa45e 100644 --- a/AUTHORS +++ b/AUTHORS @@ -334,6 +334,7 @@ Mike Fiedler (miketheman) Mike Hoyle (hoylemd) Mike Lundy Milan Lesnek +minbang930 Miro HronĨok Mulat Mekonen mrbean-bremen diff --git a/changelog/14533.bugfix.rst b/changelog/14533.bugfix.rst new file mode 100644 index 00000000000..74a44152f4a --- /dev/null +++ b/changelog/14533.bugfix.rst @@ -0,0 +1 @@ +Fixed module-level fixtures becoming unavailable to normal tests when ``--doctest-modules`` also collected the same Python module. Higher-scoped autouse fixtures defined in such modules may execute twice because ``--doctest-modules`` processes the same Python file twice; moving those fixtures to ``conftest.py`` avoids the duplicate execution. diff --git a/src/_pytest/fixtures.py b/src/_pytest/fixtures.py index 9e123be230a..2e971ddcea9 100644 --- a/src/_pytest/fixtures.py +++ b/src/_pytest/fixtures.py @@ -1676,7 +1676,6 @@ def __init__(self, session: Session) -> None: # TODO: The order of the FixtureDefs list of each arg is significant, # explain. self._arg2fixturedefs: Final[dict[str, list[FixtureDef[Any]]]] = {} - self._holderobjseen: Final[set[object]] = set() # A mapping from a node to a list of autouse fixture names it defines. # The Session entry holds global usefixtures from config. self._node_autousenames: Final[dict[nodes.Node, list[str]]] = { @@ -2031,8 +2030,6 @@ def parsefactories( assert isinstance(node_or_obj, nodes.Node) holderobj = cast(object, node_or_obj.obj) # type: ignore[attr-defined] effective_node = node_or_obj - if holderobj in self._holderobjseen: - return # Avoid accessing `@property` (and other descriptors) when iterating fixtures. if not safe_isclass(holderobj) and not isinstance(holderobj, types.ModuleType): @@ -2040,7 +2037,6 @@ def parsefactories( else: holderobj_tp = holderobj - self._holderobjseen.add(holderobj) for name in dir(holderobj): # The attribute can be an arbitrary descriptor, so the attribute # access below can raise. safe_getattr() ignores such exceptions. diff --git a/testing/test_doctest.py b/testing/test_doctest.py index 8b71dabbc77..aa47248d303 100644 --- a/testing/test_doctest.py +++ b/testing/test_doctest.py @@ -600,6 +600,31 @@ def test_doctestmodule_with_fixtures(self, pytester: Pytester): reprec = pytester.inline_run(p, "--doctest-modules") reprec.assertoutcome(passed=1) + # @pytest.mark.issue("https://github.com/pytest-dev/pytest/issues/14533") + def test_module_fixture_available_to_normal_test_with_doctestmodules( + self, pytester: Pytester + ) -> None: + """Regression test for #14533. + + Module-level fixtures collected with ``--doctest-modules`` must remain + available to normal tests in the same file. + """ + pytester.makepyfile( + """ + import pytest + + @pytest.fixture + def foo_fixture(): + return "foo" + + def test_foo(foo_fixture): + assert foo_fixture == "foo" + """ + ) + + result = pytester.runpytest("--doctest-modules") + result.assert_outcomes(passed=1) + def test_doctestmodule_three_tests(self, pytester: Pytester): p = pytester.makepyfile( """ @@ -1302,6 +1327,45 @@ def bar(): result = pytester.runpytest("--doctest-modules") result.stdout.fnmatch_lines(["*2 passed*"]) + def test_same_module_session_autouse_fixture_runs_for_module_and_doctest( + self, pytester: Pytester + ) -> None: + pytester.makepyfile( + """ + from pathlib import Path + + import pytest + + RUNS = Path("fixture-runs.txt") + + @pytest.fixture(autouse=True, scope="session") + def auto(): + RUNS.write_text( + RUNS.read_text(encoding="utf-8") + "run\\n" + if RUNS.exists() + else "run\\n", + encoding="utf-8", + ) + + def test_normal(): + assert True + + def doctest_target(): + ''' + >>> 1 + 1 + 2 + ''' + """ + ) + result = pytester.runpytest("--doctest-modules") + result.assert_outcomes(passed=2) + assert (pytester.path / "fixture-runs.txt").read_text( + encoding="utf-8" + ).splitlines() == [ + "run", + "run", + ] + @pytest.mark.parametrize("scope", SCOPES) @pytest.mark.parametrize("enable_doctest", [True, False]) def test_fixture_scopes(self, pytester, scope, enable_doctest):