Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ Levon Saldamli
Lewis Cowles
Liam DeVoe
Llandy Riveron Del Risco
Logan Dapp
Loic Esteve
lovetheguitar
Lukas Bednar
Expand Down
2 changes: 2 additions & 0 deletions changelog/13030.deprecation.rst
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions doc/en/deprecations.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <warnings>`.


.. _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``
Expand Down
5 changes: 5 additions & 0 deletions doc/en/how-to/writing_plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions src/_pytest/deprecated.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
53 changes: 53 additions & 0 deletions src/_pytest/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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


Expand Down Expand Up @@ -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."""

Expand All @@ -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.
Expand Down
3 changes: 0 additions & 3 deletions testing/test_cacheprovider.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,6 @@
import pytest


pytest_plugins = ("pytester",)


class TestNewAPI:
def test_config_cache_mkdir(self, pytester: Pytester) -> None:
pytester.makeini("[pytest]")
Expand Down
77 changes: 77 additions & 0 deletions testing/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading