Skip to content

Add discover_imports in conf, don't collect imported classes named Test* closes #12749` #12810

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 13 commits into from
Dec 1, 2024
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,7 @@ Stefanie Molin
Stefano Taschini
Steffen Allner
Stephan Obermann
Sven
Sven-Hendrik Haase
Sviatoslav Sydorenko
Sylvain Marié
Expand Down
21 changes: 21 additions & 0 deletions changelog/12749.feature.rst
Original file line number Diff line number Diff line change
@@ -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`
37 changes: 34 additions & 3 deletions doc/en/reference/reference.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://packaging.python.org/en/latest/guides/packaging-namespace-packages>`__
Expand Down Expand Up @@ -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`.

Expand Down
6 changes: 6 additions & 0 deletions src/_pytest/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions src/_pytest/python.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand All @@ -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
)
Expand Down
103 changes: 103 additions & 0 deletions testing/test_collect_imported_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Tests for the `collect_imported_tests` configuration value."""

from __future__ import annotations

import textwrap

from _pytest.pytester import Pytester
import pytest


def setup_import_class_test(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]
testpaths = "tests"
collect_imported_tests = false
"""
)

setup_import_class_test(pytester)
result = pytester.runpytest("-v")
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_import_class_test(pytester)
result = pytester.runpytest("-v")
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*",
]
)
Loading