gh-62432: unittest runner: Exit code 5 if no tests were run (#102051)

As discussed in https://discuss.python.org/t/unittest-fail-if-zero-tests-were-discovered/21498/7

It is common for test runner misconfiguration to fail to find any tests,
This should be an error.

Fixes: #62432
This commit is contained in:
Stefano Rivera 2023-04-26 18:28:46 -07:00 committed by GitHub
parent dc3f97549a
commit 76632b836c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 64 additions and 22 deletions

View File

@ -2281,7 +2281,8 @@ Loading and running tests
The *testRunner* argument can either be a test runner class or an already The *testRunner* argument can either be a test runner class or an already
created instance of it. By default ``main`` calls :func:`sys.exit` with created instance of it. By default ``main`` calls :func:`sys.exit` with
an exit code indicating success or failure of the tests run. an exit code indicating success (0) or failure (1) of the tests run.
An exit code of 5 indicates that no tests were run.
The *testLoader* argument has to be a :class:`TestLoader` instance, The *testLoader* argument has to be a :class:`TestLoader` instance,
and defaults to :data:`defaultTestLoader`. and defaults to :data:`defaultTestLoader`.

View File

@ -71,15 +71,22 @@ class Test_TestProgram(unittest.TestCase):
def testUnexpectedSuccess(self): def testUnexpectedSuccess(self):
pass pass
class FooBarLoader(unittest.TestLoader): class Empty(unittest.TestCase):
"""Test loader that returns a suite containing FooBar.""" pass
class TestLoader(unittest.TestLoader):
"""Test loader that returns a suite containing the supplied testcase."""
def __init__(self, testcase):
self.testcase = testcase
def loadTestsFromModule(self, module): def loadTestsFromModule(self, module):
return self.suiteClass( return self.suiteClass(
[self.loadTestsFromTestCase(Test_TestProgram.FooBar)]) [self.loadTestsFromTestCase(self.testcase)])
def loadTestsFromNames(self, names, module): def loadTestsFromNames(self, names, module):
return self.suiteClass( return self.suiteClass(
[self.loadTestsFromTestCase(Test_TestProgram.FooBar)]) [self.loadTestsFromTestCase(self.testcase)])
def test_defaultTest_with_string(self): def test_defaultTest_with_string(self):
class FakeRunner(object): class FakeRunner(object):
@ -92,7 +99,7 @@ class Test_TestProgram(unittest.TestCase):
runner = FakeRunner() runner = FakeRunner()
program = unittest.TestProgram(testRunner=runner, exit=False, program = unittest.TestProgram(testRunner=runner, exit=False,
defaultTest='test.test_unittest', defaultTest='test.test_unittest',
testLoader=self.FooBarLoader()) testLoader=self.TestLoader(self.FooBar))
sys.argv = old_argv sys.argv = old_argv
self.assertEqual(('test.test_unittest',), program.testNames) self.assertEqual(('test.test_unittest',), program.testNames)
@ -108,7 +115,7 @@ class Test_TestProgram(unittest.TestCase):
program = unittest.TestProgram( program = unittest.TestProgram(
testRunner=runner, exit=False, testRunner=runner, exit=False,
defaultTest=['test.test_unittest', 'test.test_unittest2'], defaultTest=['test.test_unittest', 'test.test_unittest2'],
testLoader=self.FooBarLoader()) testLoader=self.TestLoader(self.FooBar))
sys.argv = old_argv sys.argv = old_argv
self.assertEqual(['test.test_unittest', 'test.test_unittest2'], self.assertEqual(['test.test_unittest', 'test.test_unittest2'],
program.testNames) program.testNames)
@ -118,7 +125,7 @@ class Test_TestProgram(unittest.TestCase):
program = unittest.main(exit=False, program = unittest.main(exit=False,
argv=["foobar"], argv=["foobar"],
testRunner=unittest.TextTestRunner(stream=stream), testRunner=unittest.TextTestRunner(stream=stream),
testLoader=self.FooBarLoader()) testLoader=self.TestLoader(self.FooBar))
self.assertTrue(hasattr(program, 'result')) self.assertTrue(hasattr(program, 'result'))
out = stream.getvalue() out = stream.getvalue()
self.assertIn('\nFAIL: testFail ', out) self.assertIn('\nFAIL: testFail ', out)
@ -130,13 +137,13 @@ class Test_TestProgram(unittest.TestCase):
def test_Exit(self): def test_Exit(self):
stream = BufferedWriter() stream = BufferedWriter()
self.assertRaises( with self.assertRaises(SystemExit) as cm:
SystemExit, unittest.main(
unittest.main, argv=["foobar"],
argv=["foobar"], testRunner=unittest.TextTestRunner(stream=stream),
testRunner=unittest.TextTestRunner(stream=stream), exit=True,
exit=True, testLoader=self.TestLoader(self.FooBar))
testLoader=self.FooBarLoader()) self.assertEqual(cm.exception.code, 1)
out = stream.getvalue() out = stream.getvalue()
self.assertIn('\nFAIL: testFail ', out) self.assertIn('\nFAIL: testFail ', out)
self.assertIn('\nERROR: testError ', out) self.assertIn('\nERROR: testError ', out)
@ -147,12 +154,11 @@ class Test_TestProgram(unittest.TestCase):
def test_ExitAsDefault(self): def test_ExitAsDefault(self):
stream = BufferedWriter() stream = BufferedWriter()
self.assertRaises( with self.assertRaises(SystemExit):
SystemExit, unittest.main(
unittest.main, argv=["foobar"],
argv=["foobar"], testRunner=unittest.TextTestRunner(stream=stream),
testRunner=unittest.TextTestRunner(stream=stream), testLoader=self.TestLoader(self.FooBar))
testLoader=self.FooBarLoader())
out = stream.getvalue() out = stream.getvalue()
self.assertIn('\nFAIL: testFail ', out) self.assertIn('\nFAIL: testFail ', out)
self.assertIn('\nERROR: testError ', out) self.assertIn('\nERROR: testError ', out)
@ -161,6 +167,17 @@ class Test_TestProgram(unittest.TestCase):
'expected failures=1, unexpected successes=1)\n') 'expected failures=1, unexpected successes=1)\n')
self.assertTrue(out.endswith(expected)) self.assertTrue(out.endswith(expected))
def test_ExitEmptySuite(self):
stream = BufferedWriter()
with self.assertRaises(SystemExit) as cm:
unittest.main(
argv=["empty"],
testRunner=unittest.TextTestRunner(stream=stream),
testLoader=self.TestLoader(self.Empty))
self.assertEqual(cm.exception.code, 5)
out = stream.getvalue()
self.assertIn('\nNO TESTS RAN\n', out)
class InitialisableProgram(unittest.TestProgram): class InitialisableProgram(unittest.TestProgram):
exit = False exit = False

View File

@ -451,6 +451,7 @@ class Test_TestResult(unittest.TestCase):
stream = BufferedWriter() stream = BufferedWriter()
runner = unittest.TextTestRunner(stream=stream, failfast=True) runner = unittest.TextTestRunner(stream=stream, failfast=True)
def test(result): def test(result):
result.testsRun += 1
self.assertTrue(result.failfast) self.assertTrue(result.failfast)
result = runner.run(test) result = runner.run(test)
stream.flush() stream.flush()

View File

@ -577,6 +577,16 @@ class TestClassCleanup(unittest.TestCase):
'inner setup', 'inner test', 'inner cleanup', 'inner setup', 'inner test', 'inner cleanup',
'end outer test', 'outer cleanup']) 'end outer test', 'outer cleanup'])
def test_run_empty_suite_error_message(self):
class EmptyTest(unittest.TestCase):
pass
suite = unittest.defaultTestLoader.loadTestsFromTestCase(EmptyTest)
runner = getRunner()
runner.run(suite)
self.assertIn("\nNO TESTS RAN\n", runner.stream.getvalue())
class TestModuleCleanUp(unittest.TestCase): class TestModuleCleanUp(unittest.TestCase):
def test_add_and_do_ModuleCleanup(self): def test_add_and_do_ModuleCleanup(self):

View File

@ -9,6 +9,7 @@ from . import loader, runner
from .signals import installHandler from .signals import installHandler
__unittest = True __unittest = True
_NO_TESTS_EXITCODE = 5
MAIN_EXAMPLES = """\ MAIN_EXAMPLES = """\
Examples: Examples:
@ -279,6 +280,12 @@ class TestProgram(object):
testRunner = self.testRunner testRunner = self.testRunner
self.result = testRunner.run(self.test) self.result = testRunner.run(self.test)
if self.exit: if self.exit:
sys.exit(not self.result.wasSuccessful()) if self.result.testsRun == 0:
sys.exit(_NO_TESTS_EXITCODE)
elif self.result.wasSuccessful():
sys.exit(0)
else:
sys.exit(1)
main = TestProgram main = TestProgram

View File

@ -274,6 +274,8 @@ class TextTestRunner(object):
infos.append("failures=%d" % failed) infos.append("failures=%d" % failed)
if errored: if errored:
infos.append("errors=%d" % errored) infos.append("errors=%d" % errored)
elif run == 0:
self.stream.write("NO TESTS RAN")
else: else:
self.stream.write("OK") self.stream.write("OK")
if skipped: if skipped:

View File

@ -1513,6 +1513,7 @@ Vlad Riscutia
Wes Rishel Wes Rishel
Daniel Riti Daniel Riti
Juan M. Bello Rivas Juan M. Bello Rivas
Stefano Rivera
Llandy Riveron Del Risco Llandy Riveron Del Risco
Mohd Sanad Zaki Rizvi Mohd Sanad Zaki Rizvi
Davide Rizzo Davide Rizzo

View File

@ -0,0 +1,3 @@
The :mod:`unittest` runner will now exit with status code 5 if no tests
were run. It is common for test runner misconfiguration to fail to find
any tests, this should be an error.