diff --git a/AUTHORS b/AUTHORS index 374e6ad9bcc..b1dd40dbd4c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -400,6 +400,7 @@ Stefanie Molin Stefano Taschini Steffen Allner Stephan Obermann +Sven Sven-Hendrik Haase Sviatoslav Sydorenko Sylvain MariƩ diff --git a/changelog/12749.feature.rst b/changelog/12749.feature.rst new file mode 100644 index 00000000000..c3b7ca5d321 --- /dev/null +++ b/changelog/12749.feature.rst @@ -0,0 +1,21 @@ +pytest traditionally collects classes/functions in the test module namespace even if they are imported from another file. + +For example: + +.. code-block:: python + + # contents of src/domain.py + class Testament: ... + + + # contents of tests/test_testament.py + from domain import Testament + + + def test_testament(): ... + +In this scenario with the default options, pytest will collect the class `Testament` from `tests/test_testament.py` because it starts with `Test`, even though in this case it is a production class being imported in the test module namespace. + +This behavior can now be prevented by setting the new :confval:`collect_imported_tests` configuration option to ``false``, which will make pytest collect classes/functions from test files **only** if they are defined in that file. + +-- by :user:`FreerGit` diff --git a/doc/en/reference/reference.rst b/doc/en/reference/reference.rst index f7dfb3ffa71..1e550a115c8 100644 --- a/doc/en/reference/reference.rst +++ b/doc/en/reference/reference.rst @@ -1301,6 +1301,40 @@ passed multiple times. The expected format is ``name=value``. For example:: variables, that will be expanded. For more information about cache plugin please refer to :ref:`cache_provider`. +.. confval:: collect_imported_tests + + .. versionadded:: 8.4 + + Setting this to ``false`` will make pytest collect classes/functions from test + files **only** if they are defined in that file (as opposed to imported there). + + .. code-block:: ini + + [pytest] + collect_imported_tests = false + + Default: ``true`` + + pytest traditionally collects classes/functions in the test module namespace even if they are imported from another file. + + For example: + + .. code-block:: python + + # contents of src/domain.py + class Testament: ... + + + # contents of tests/test_testament.py + from domain import Testament + + + def test_testament(): ... + + In this scenario, with the default options, pytest will collect the class `Testament` from `tests/test_testament.py` because it starts with `Test`, even though in this case it is a production class being imported in the test module namespace. + + Set ``collected_imported_tests`` to ``false`` in the configuration file prevents that. + .. confval:: consider_namespace_packages Controls if pytest should attempt to identify `namespace packages `__ @@ -1838,11 +1872,8 @@ passed multiple times. The expected format is ``name=value``. For example:: pytest testing doc - .. confval:: tmp_path_retention_count - - How many sessions should we keep the `tmp_path` directories, according to `tmp_path_retention_policy`. diff --git a/src/_pytest/main.py b/src/_pytest/main.py index e5534e98d69..41063a9bc18 100644 --- a/src/_pytest/main.py +++ b/src/_pytest/main.py @@ -78,6 +78,12 @@ def pytest_addoption(parser: Parser) -> None: type="args", default=[], ) + parser.addini( + "collect_imported_tests", + "Whether to collect tests in imported modules outside `testpaths`", + type="bool", + default=True, + ) group = parser.getgroup("general", "Running and selection options") group._addoption( "-x", diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 9c54dd20f80..6e7360c5b7d 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -405,6 +405,7 @@ def collect(self) -> Iterable[nodes.Item | nodes.Collector]: # __dict__ is definition ordered. seen: set[str] = set() dict_values: list[list[nodes.Item | nodes.Collector]] = [] + collect_imported_tests = self.session.config.getini("collect_imported_tests") ihook = self.ihook for dic in dicts: values: list[nodes.Item | nodes.Collector] = [] @@ -416,6 +417,13 @@ def collect(self) -> Iterable[nodes.Item | nodes.Collector]: if name in seen: continue seen.add(name) + + if not collect_imported_tests and isinstance(self, Module): + # Do not collect functions and classes from other modules. + if inspect.isfunction(obj) or inspect.isclass(obj): + if obj.__module__ != self._getobj().__name__: + continue + res = ihook.pytest_pycollect_makeitem( collector=self, name=name, obj=obj ) diff --git a/testing/test_collect_imported_tests.py b/testing/test_collect_imported_tests.py new file mode 100644 index 00000000000..28b92e17f6f --- /dev/null +++ b/testing/test_collect_imported_tests.py @@ -0,0 +1,102 @@ +"""Tests for the `collect_imported_tests` configuration value.""" + +from __future__ import annotations + +import textwrap + +from _pytest.pytester import Pytester +import pytest + + +def setup_files(pytester: Pytester) -> None: + src_dir = pytester.mkdir("src") + tests_dir = pytester.mkdir("tests") + src_file = src_dir / "foo.py" + + src_file.write_text( + textwrap.dedent("""\ + class Testament: + def test_collections(self): + pass + + def test_testament(): pass + """), + encoding="utf-8", + ) + + test_file = tests_dir / "foo_test.py" + test_file.write_text( + textwrap.dedent("""\ + from foo import Testament, test_testament + + class TestDomain: + def test(self): + testament = Testament() + assert testament + """), + encoding="utf-8", + ) + + pytester.syspathinsert(src_dir) + + +def test_collect_imports_disabled(pytester: Pytester) -> None: + """ + When collect_imported_tests is disabled, only objects in the + test modules are collected as tests, so the imported names (`Testament` and `test_testament`) + are not collected. + """ + pytester.makeini( + """ + [pytest] + collect_imported_tests = false + """ + ) + + setup_files(pytester) + result = pytester.runpytest("-v", "tests") + result.stdout.fnmatch_lines( + [ + "tests/foo_test.py::TestDomain::test PASSED*", + ] + ) + + # Ensure that the hooks were only called for the collected item. + reprec = result.reprec # type:ignore[attr-defined] + reports = reprec.getreports("pytest_collectreport") + [modified] = reprec.getcalls("pytest_collection_modifyitems") + [item_collected] = reprec.getcalls("pytest_itemcollected") + + assert [x.nodeid for x in reports] == [ + "", + "tests/foo_test.py::TestDomain", + "tests/foo_test.py", + "tests", + ] + assert [x.nodeid for x in modified.items] == ["tests/foo_test.py::TestDomain::test"] + assert item_collected.item.nodeid == "tests/foo_test.py::TestDomain::test" + + +@pytest.mark.parametrize("configure_ini", [False, True]) +def test_collect_imports_enabled(pytester: Pytester, configure_ini: bool) -> None: + """ + When collect_imported_tests is enabled (the default), all names in the + test modules are collected as tests. + """ + if configure_ini: + pytester.makeini( + """ + [pytest] + collect_imported_tests = true + """ + ) + + setup_files(pytester) + result = pytester.runpytest("-v", "tests") + result.stdout.fnmatch_lines( + [ + "tests/foo_test.py::Testament::test_collections PASSED*", + "tests/foo_test.py::test_testament PASSED*", + "tests/foo_test.py::TestDomain::test PASSED*", + ] + )