Skip to content

Commit

Permalink
Merge pull request #12810 from FreerGit/dont-auto-discover-feat
Browse files Browse the repository at this point in the history
Add `discover_imports` in conf, don't collect imported classes named Test* closes #12749`
  • Loading branch information
Zac-HD authored Dec 1, 2024
2 parents e135d76 + d2327d9 commit 9d4f36d
Show file tree
Hide file tree
Showing 6 changed files with 172 additions and 3 deletions.
1 change: 1 addition & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,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 @@ -1861,11 +1895,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 @@ -77,6 +77,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 @@ -397,6 +397,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 @@ -408,6 +409,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
102 changes: 102 additions & 0 deletions testing/test_collect_imported_tests.py
Original file line number Diff line number Diff line change
@@ -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*",
]
)

0 comments on commit 9d4f36d

Please sign in to comment.