From d229708d26c02d3188419266878deeda1ce7a1c0 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Tue, 22 Sep 2026 13:00:35 +0200 Subject: [PATCH] python: warn when an abstract test class swallows its tests Abstract classes have not been collected since #12318, which fixed the `Can't instantiate abstract class` regression of #12275. That is right for a base class written to be subclassed, but a leaf class that merely forgot to implement a blank looks exactly the same, so its tests stop running and nothing says so -- reported four times now, most recently in #15080/#15081. Warn when an abstract class matches the test class naming conventions, carries tests, declares no abstract method of its own, and does not set `__test__` in its own body. Declaring a blank is read as meaning to be a base class; the `__test__` opt-out is read off the class rather than through inheritance, because an inherited `__test__` would silence every subclass too. The unittest hook warns for itself and returns a result rather than None, both because unittest classes are collected regardless of `python_classes` and to keep the python plugin from warning about them a second time. Co-Authored-By: Claude Opus 5 (1M context) via Claude Code --- changelog/13546.improvement.rst | 7 ++++ src/_pytest/python.py | 48 ++++++++++++++++++++++ src/_pytest/unittest.py | 8 +++- testing/python/collect.py | 71 ++++++++++++++++++++++++++++++++- testing/test_unittest.py | 38 +++++++++++++++++- 5 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 changelog/13546.improvement.rst diff --git a/changelog/13546.improvement.rst b/changelog/13546.improvement.rst new file mode 100644 index 00000000000..d28d50a8cd2 --- /dev/null +++ b/changelog/13546.improvement.rst @@ -0,0 +1,7 @@ +pytest now warns with :class:`~pytest.PytestCollectionWarning` when a test class is skipped +because it is abstract, but looks like it was meant to be collected -- it inherits abstract +methods it does not implement and does not declare any of its own, yet carries tests. + +Such a class is most often a test class whose base class grew a new abstract method, which +silently stops its tests from running. Classes that declare abstract methods of their own are +still skipped silently, as are classes that set ``__test__ = False`` in their body. diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 792d0d5b4f3..812160ba889 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -227,6 +227,52 @@ def pytest_collect_file(file_path: Path, parent: nodes.Collector) -> Module | No return None +def _warn_if_abstract_tests_go_dark( + collector: Module | Class, name: str, obj: object +) -> None: + """Warn if an abstract class silently swallows tests that look collectible. + + Abstract classes are not collected (#12275), which is right for a base class + written to be subclassed, but indistinguishable from a leaf class that failed + to implement a blank -- for instance because a base class grew a new abstract + method. In that case the tests stop running and nothing says so (#13546). + + A class that declares abstract methods of its own is taken to mean it; one + that only inherits unimplemented ones, yet carries tests, is warned about. + ``__test__`` in the class body opts out, deliberately read off the class + itself, since an inherited ``__test__`` would silence every subclass too. + """ + assert isinstance(obj, type) + missing: frozenset[str] = getattr(obj, "__abstractmethods__", frozenset()) + if not missing or "__test__" in obj.__dict__: + return + if any(attr in obj.__dict__ for attr in missing): + # Declares blanks of its own, so it is meant as a base class. + return + if not any( + collector.istestfunction(value, attr) + for klass in obj.__mro__ + for attr, value in klass.__dict__.items() + ): + return + + def declared_in(attr: str) -> str: + return next(k.__name__ for k in obj.__mro__ if attr in k.__dict__) + + unimplemented = ", ".join( + f"{attr!r} (declared abstract in {declared_in(attr)!r})" + for attr in sorted(missing) + ) + collector.warn( + PytestCollectionWarning( + f"cannot collect test class {name!r} because it is abstract: " + f"it does not implement {unimplemented}. Set '__test__ = False' in " + f"its body if it is meant to be a base class " + f"(from: {collector.nodeid})" + ) + ) + + def path_matches_patterns(path: Path, patterns: Iterable[str]) -> bool: """Return whether path matches any of the patterns in the list of globs given.""" return any(fnmatch_ex(pattern, path) for pattern in patterns) @@ -245,6 +291,8 @@ def pytest_pycollect_makeitem( if safe_isclass(obj): if collector.istestclass(obj, name): return Class.from_parent(collector, name=name, obj=obj) + if collector.classnamefilter(name) or collector.isnosetest(obj): + _warn_if_abstract_tests_go_dark(collector, name, obj) elif collector.istestfunction(obj, name): # mock seems to store unbound methods (issue473), normalize it. obj = getattr(obj, "__func__", obj) diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index 2c9e0dcb80b..94f2e064b8a 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -31,6 +31,7 @@ from _pytest.outcomes import fail from _pytest.outcomes import skip from _pytest.outcomes import xfail +from _pytest.python import _warn_if_abstract_tests_go_dark from _pytest.python import Class from _pytest.python import Function from _pytest.python import Module @@ -58,7 +59,7 @@ def pytest_pycollect_makeitem( collector: Module | Class, name: str, obj: object -) -> UnitTestCase | None: +) -> UnitTestCase | list[Item | Collector] | None: try: # Has unittest been imported? ut = sys.modules["unittest"] @@ -71,7 +72,10 @@ def pytest_pycollect_makeitem( # Is obj a concrete class? # Abstract classes can't be instantiated so no point collecting them. if inspect.isabstract(obj): - return None + # Return a result rather than None so that the python plugin does not + # warn a second time about a TestCase subclass named like a test class. + _warn_if_abstract_tests_go_dark(collector, name, obj) + return [] # Yes, so let's collect it. return UnitTestCase.from_parent(collector, name=name, obj=obj) diff --git a/testing/python/collect.py b/testing/python/collect.py index dddd15b8f67..80be0650196 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -352,10 +352,79 @@ class TestConcrete(TestPartial): def abstract2(self): pass """ ) - result = pytester.runpytest() + result = pytester.runpytest("-Wignore::pytest.PytestCollectionWarning") assert result.ret == ExitCode.OK result.assert_outcomes(passed=1) + def test_abstract_class_missing_implementation_warns( + self, pytester: Pytester + ) -> None: + """A class that inherits tests but no longer instantiates says so (#13546). + + ``TestForgot`` looks like a leaf test class and carries an inherited + test, but is still abstract, so its tests never run. + """ + pytester.makepyfile( + """ + import abc + + class TestBase(abc.ABC): + @abc.abstractmethod + def impl(self): pass + + def test_it(self): + assert self.impl() == 1 + + class TestForgot(TestBase): + pass + + class TestOk(TestBase): + def impl(self): + return 1 + """ + ) + result = pytester.runpytest("-Wdefault::pytest.PytestCollectionWarning") + result.assert_outcomes(passed=1, warnings=1) + result.stdout.fnmatch_lines( + [ + "*PytestCollectionWarning: cannot collect test class 'TestForgot'" + " because it is abstract: it does not implement 'impl'" + " (declared abstract in 'TestBase')*" + ] + ) + + def test_abstract_base_class_is_not_warned_about(self, pytester: Pytester) -> None: + """A class declaring blanks of its own means to be a base class (#13546). + + ``__test__`` in the class body opts a class out of the warning even when + it declares no blanks of its own. + """ + pytester.makepyfile( + """ + import abc + + class TestBase(abc.ABC): + @abc.abstractmethod + def impl(self): pass + + def test_it(self): + assert self.impl() == 1 + + class TestStillAbstract(TestBase): + __test__ = False + + class TestNoTests(abc.ABC): + @abc.abstractmethod + def impl(self): pass + + class TestOk(TestBase): + def impl(self): + return 1 + """ + ) + result = pytester.runpytest("-Wdefault::pytest.PytestCollectionWarning") + result.assert_outcomes(passed=1, warnings=0) + class TestFunction: def test_getmodulecollector(self, pytester: Pytester) -> None: diff --git a/testing/test_unittest.py b/testing/test_unittest.py index 203503ff06f..01be72311ca 100644 --- a/testing/test_unittest.py +++ b/testing/test_unittest.py @@ -1738,6 +1738,42 @@ class TestConcrete(TestPartial): def abstract2(self): pass """ ) - result = pytester.runpytest() + result = pytester.runpytest("-Wignore::pytest.PytestCollectionWarning") assert result.ret == ExitCode.OK result.assert_outcomes(passed=1) + + +def test_abstract_testcase_missing_implementation_warns(pytester: Pytester) -> None: + """A TestCase that inherits tests but no longer instantiates says so (#13546). + + The class name deliberately does not match ``python_classes``: unittest + classes are collected regardless of it. + """ + pytester.makepyfile( + """ + import abc + import unittest + + class BaseTestCase(unittest.TestCase, abc.ABC): + @abc.abstractmethod + def get_application(self): pass + + def test_it(self): + assert self.get_application() == 1 + + class InvalidTestCase(BaseTestCase): + pass + + class ValidTestCase(BaseTestCase): + def get_application(self): + return 1 + """ + ) + result = pytester.runpytest("-Wdefault::pytest.PytestCollectionWarning") + result.assert_outcomes(passed=1, warnings=1) + result.stdout.fnmatch_lines( + [ + "*PytestCollectionWarning: cannot collect test class 'InvalidTestCase'" + " because it is abstract: it does not implement 'get_application'*" + ] + )