diff --git a/AUTHORS b/AUTHORS index bd1e27cb37d..2db9c7acb92 100644 --- a/AUTHORS +++ b/AUTHORS @@ -290,6 +290,7 @@ Levon Saldamli Lewis Cowles Liam DeVoe Llandy Riveron Del Risco +Logan Dapp Loic Esteve lovetheguitar Lukas Bednar diff --git a/changelog/13030.deprecation.rst b/changelog/13030.deprecation.rst new file mode 100644 index 00000000000..21949d62e4c --- /dev/null +++ b/changelog/13030.deprecation.rst @@ -0,0 +1,2 @@ +Defining :globalvar:`pytest_plugins` in a test module is now deprecated and emits a :class:`~pytest.PytestDeprecationWarning` when more than one test file is collected, since the plugins affect the entire test suite rather than only that module. +See :ref:`pytest-plugins-in-test-modules` for details. diff --git a/doc/en/deprecations.rst b/doc/en/deprecations.rst index 59f11c4ec09..395ffed8bfd 100644 --- a/doc/en/deprecations.rst +++ b/doc/en/deprecations.rst @@ -15,6 +15,30 @@ Below is a complete list of all pytest features which are considered deprecated. :class:`~pytest.PytestWarning` or subclasses, which can be filtered using :ref:`standard warning filters `. +.. _pytest-plugins-in-test-modules: + +``pytest_plugins`` in test modules +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. deprecated:: 9.2 + +Defining :globalvar:`pytest_plugins` in a test module is deprecated, because the +plugins it lists are activated for the *entire* test suite, not only for that +module. This is surprising, and the outcome of other test modules can depend on +whether that module happens to be collected. + +To keep single-file test suites (common for testing plugins) working, the +:class:`~pytest.PytestDeprecationWarning` is only emitted when more than one +test file is collected. There is no removal version yet. + +Move the variable to a ``conftest.py`` file at the rootdir instead: + +.. code-block:: python + + # content of conftest.py + pytest_plugins = ["myapp.testsupport.myplugin"] + + .. _callspec2-renamed: ``_pytest.python.CallSpec2`` renamed to ``CallSpec`` diff --git a/doc/en/how-to/writing_plugins.rst b/doc/en/how-to/writing_plugins.rst index 079bde6274f..743fe73e69c 100644 --- a/doc/en/how-to/writing_plugins.rst +++ b/doc/en/how-to/writing_plugins.rst @@ -278,6 +278,11 @@ of the variable will also be loaded as plugins, and so on. :globalvar:`pytest_plugins` in any ``conftest.py`` file which is not located in the tests root directory is deprecated, and will raise a warning. + Likewise, defining :globalvar:`pytest_plugins` in a test module affects the + entire test suite, so it is deprecated and pytest emits a + :class:`~pytest.PytestDeprecationWarning` when it finds one while collecting + more than one test file. See :ref:`pytest-plugins-in-test-modules`. + This mechanism makes it easy to share fixtures within applications or even external applications without the need to create external plugins using the :std:doc:`entry point packaging metadata diff --git a/src/_pytest/deprecated.py b/src/_pytest/deprecated.py index a94aeb735e5..4ee5ada0762 100644 --- a/src/_pytest/deprecated.py +++ b/src/_pytest/deprecated.py @@ -53,6 +53,17 @@ ) +# Not tied to a removal version yet (#13030). +PYTEST_PLUGINS_IN_TEST_MODULE = UnformattedWarning( + PytestDeprecationWarning, + "Defining 'pytest_plugins' in test module {nodeid} affects the entire test suite " + "instead of just this module.\n" + "Please move it to a top level conftest file at the rootdir.\n" + "See https://docs.pytest.org/en/stable/deprecations.html" + "#pytest-plugins-in-test-modules", +) + + HOOK_LEGACY_MARKING = UnformattedWarning( PytestRemovedIn10Warning, "The hook{type} {fullname} uses old-style configuration options (marks or attributes).\n" diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 1ce7f71a7bd..5281347b714 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -57,6 +57,7 @@ from _pytest.config.argparsing import Parser from _pytest.deprecated import CALLSPEC2_RENAMED from _pytest.deprecated import check_ispytest +from _pytest.deprecated import PYTEST_PLUGINS_IN_TEST_MODULE from _pytest.fixtures import _resolve_args_directness from _pytest.fixtures import FixtureDef from _pytest.fixtures import FixtureRequest @@ -77,10 +78,12 @@ from _pytest.pathlib import import_path from _pytest.pathlib import ImportPathMismatchError from _pytest.pathlib import scandir +from _pytest.reports import CollectReport from _pytest.scope import Scope from _pytest.scope import ScopeName from _pytest.stash import StashKey from _pytest.warning_types import PytestCollectionWarning +from _pytest.warning_types import PytestDeprecationWarning from _pytest.warning_types import PytestReturnNotNoneWarning @@ -574,6 +577,18 @@ def importtestmodule( return mod +@dataclasses.dataclass +class _CollectedTestModules: + """Test modules collected so far, to warn about ``pytest_plugins`` (#13030).""" + + count: int = 0 + # Held only until the second test module is collected. + first: Module | None = None + + +_collected_test_modules_key = StashKey[_CollectedTestModules]() + + class Module(nodes.File, PyCollector): """Collector for test classes and functions in a Python module.""" @@ -584,8 +599,46 @@ def collect(self) -> Iterable[nodes.Item | nodes.Collector]: self._register_setup_module_fixture() self._register_setup_function_fixture() self.session._fixturemanager.parsefactories(self) + self._warn_pytest_plugins_in_test_module() return super().collect() + def _warn_pytest_plugins_in_test_module(self) -> None: + """Warn when a test module defines ``pytest_plugins`` (#13030). + + Such plugins affect the entire test suite rather than only the + declaring module. A single-file suite (common for plugin tests) cannot + be affected, so the warning is held back until a second test module + is collected. + """ + collected = self.config.stash.setdefault( + _collected_test_modules_key, _CollectedTestModules() + ) + collected.count += 1 + if collected.count == 1: + collected.first = self + return + if collected.first is not None: + first, collected.first = collected.first, None + first._warn_pytest_plugins_retroactively() + self._warn_if_pytest_plugins() + + def _warn_if_pytest_plugins(self) -> None: + if hasattr(self.obj, "pytest_plugins"): + self.warn(PYTEST_PLUGINS_IN_TEST_MODULE.format(nodeid=self.nodeid)) + + def _warn_pytest_plugins_retroactively(self) -> None: + """Warn about this already collected module while another one is collected. + + If the warning is turned into an error, report it as a collection error + of this module instead of the module currently being collected. + """ + try: + self._warn_if_pytest_plugins() + except PytestDeprecationWarning: + longrepr = self.repr_failure(ExceptionInfo.from_current()) + report = CollectReport(self.id, "failed", longrepr, []) + self.ihook.pytest_collectreport(report=report) + def _register_setup_module_fixture(self) -> None: """Register an autouse, module-scoped fixture for the collected module object that invokes setUpModule/tearDownModule if either or both are available. diff --git a/testing/test_cacheprovider.py b/testing/test_cacheprovider.py index a38a72c6011..bb4fd9f6ad1 100644 --- a/testing/test_cacheprovider.py +++ b/testing/test_cacheprovider.py @@ -19,9 +19,6 @@ import pytest -pytest_plugins = ("pytester",) - - class TestNewAPI: def test_config_cache_mkdir(self, pytester: Pytester) -> None: pytester.makeini("[pytest]") diff --git a/testing/test_config.py b/testing/test_config.py index 282e66409f7..9a953a6e7eb 100644 --- a/testing/test_config.py +++ b/testing/test_config.py @@ -3120,6 +3120,83 @@ def test_pytest_plugins_in_non_top_level_conftest_unsupported_no_false_positives msg = "Defining 'pytest_plugins' in a non-top-level conftest is no longer supported" assert msg not in res.stdout.str() + @pytest.mark.parametrize("module", ["test_a", "test_b"]) + @pytest.mark.filterwarnings("default") + def test_pytest_plugins_in_test_module_warns( + self, pytester: Pytester, module: str + ) -> None: + """Using pytest_plugins in a test module affects the whole suite (#13030). + + Covers both the first collected module (warned retroactively) and a later one. + """ + pytester.makepyfile( + test_a="def test_a(): pass", + test_b="def test_b(): pass", + ) + pytester.makepyfile( + **{ + module: f""" + pytest_plugins = ['capture'] + + def {module}(): pass + """ + } + ) + res = pytester.runpytest() + assert res.ret == 0 + res.stdout.fnmatch_lines( + [ + f"*{module}.py:1: PytestDeprecationWarning: Defining 'pytest_plugins' " + f"in test module {module}.py affects the entire test suite*", + "*2 passed, 1 warning*", + ] + ) + + def test_pytest_plugins_in_single_test_module_no_warning( + self, pytester: Pytester + ) -> None: + """A single-file test suite (e.g. a plugin's own tests) stays quiet (#13030).""" + pytester.makepyfile( + """ + pytest_plugins = ['capture'] + + def test_func(): pass + """ + ) + res = pytester.runpytest() + assert res.ret == 0 + assert "Defining 'pytest_plugins' in test module" not in res.stdout.str() + + @pytest.mark.parametrize("module", ["test_a", "test_b"]) + def test_pytest_plugins_in_test_module_as_error( + self, pytester: Pytester, module: str + ) -> None: + """As an error, it is a collection error of the defining module (#13030).""" + pytester.makepyfile( + test_a="def test_a(): pass", + test_b="def test_b(): pass", + ) + pytester.makepyfile( + **{ + module: f""" + pytest_plugins = ['capture'] + + def {module}(): pass + """ + } + ) + res = pytester.runpytest("-W", "error::pytest.PytestDeprecationWarning") + assert res.ret == pytest.ExitCode.INTERRUPTED + res.stdout.fnmatch_lines( + [ + f"*ERROR collecting {module}.py*", + f"*in test module {module}.py affects*", + "*1 error*", + ] + ) + other = "test_b" if module == "test_a" else "test_a" + res.stdout.no_fnmatch_line(f"*ERROR collecting {other}.py*") + def test_conftest_import_error_repr(tmp_path: Path) -> None: """`ConftestImportFailure` should use a short error message and readable