diff --git a/changelog/12275.bugfix.rst b/changelog/12275.bugfix.rst new file mode 100644 index 00000000000..2d040a3a063 --- /dev/null +++ b/changelog/12275.bugfix.rst @@ -0,0 +1 @@ +Fix collection error upon encountering an :mod:`abstract ` class, including abstract `unittest.TestCase` subclasses. diff --git a/src/_pytest/python.py b/src/_pytest/python.py index 68eceb7f4f3..5196b9fe891 100644 --- a/src/_pytest/python.py +++ b/src/_pytest/python.py @@ -368,7 +368,11 @@ def istestfunction(self, obj: object, name: str) -> bool: return False def istestclass(self, obj: object, name: str) -> bool: - return self.classnamefilter(name) or self.isnosetest(obj) + if not (self.classnamefilter(name) or self.isnosetest(obj)): + return False + if inspect.isabstract(obj): + return False + return True def _matches_prefix_or_glob_option(self, option_name: str, name: str) -> bool: """Check if the given name matches the prefix or glob-pattern defined diff --git a/src/_pytest/unittest.py b/src/_pytest/unittest.py index 8f1791bf744..c6eebbcadf0 100644 --- a/src/_pytest/unittest.py +++ b/src/_pytest/unittest.py @@ -1,6 +1,7 @@ # mypy: allow-untyped-defs """Discover and run std-library "unittest" style tests.""" +import inspect import sys import traceback import types @@ -49,14 +50,19 @@ def pytest_pycollect_makeitem( collector: Union[Module, Class], name: str, obj: object ) -> Optional["UnitTestCase"]: - # Has unittest been imported and is obj a subclass of its TestCase? + # Has unittest been imported? try: ut = sys.modules["unittest"] + # Is obj a subclass of unittest.TestCase? # Type ignored because `ut` is an opaque module. if not issubclass(obj, ut.TestCase): # type: ignore return None except Exception: return None + # Is obj a concrete class? + # Abstract classes can't be instantiated so no point collecting them. + if inspect.isabstract(obj): + return None # 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 a1a7dc8978d..843fa3c0e6b 100644 --- a/testing/python/collect.py +++ b/testing/python/collect.py @@ -262,6 +262,32 @@ def prop(self): result = pytester.runpytest() assert result.ret == ExitCode.NO_TESTS_COLLECTED + def test_abstract_class_is_not_collected(self, pytester: Pytester) -> None: + """Regression test for #12275 (non-unittest version).""" + pytester.makepyfile( + """ + import abc + + class TestBase(abc.ABC): + @abc.abstractmethod + def abstract1(self): pass + + @abc.abstractmethod + def abstract2(self): pass + + def test_it(self): pass + + class TestPartial(TestBase): + def abstract1(self): pass + + class TestConcrete(TestPartial): + def abstract2(self): pass + """ + ) + result = pytester.runpytest() + assert result.ret == ExitCode.OK + result.assert_outcomes(passed=1) + class TestFunction: def test_getmodulecollector(self, pytester: Pytester) -> None: diff --git a/testing/test_unittest.py b/testing/test_unittest.py index d726e74d603..f73e083be78 100644 --- a/testing/test_unittest.py +++ b/testing/test_unittest.py @@ -1640,3 +1640,31 @@ def test_it2(self): pass assert skipped == 1 assert failed == 0 assert reprec.ret == ExitCode.NO_TESTS_COLLECTED + + +def test_abstract_testcase_is_not_collected(pytester: Pytester) -> None: + """Regression test for #12275.""" + pytester.makepyfile( + """ + import abc + import unittest + + class TestBase(unittest.TestCase, abc.ABC): + @abc.abstractmethod + def abstract1(self): pass + + @abc.abstractmethod + def abstract2(self): pass + + def test_it(self): pass + + class TestPartial(TestBase): + def abstract1(self): pass + + class TestConcrete(TestPartial): + def abstract2(self): pass + """ + ) + result = pytester.runpytest() + assert result.ret == ExitCode.OK + result.assert_outcomes(passed=1)