Skip to content

Commit 76632b8

Browse files
authored
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
1 parent dc3f975 commit 76632b8

File tree

8 files changed

+64
-22
lines changed

8 files changed

+64
-22
lines changed

Doc/library/unittest.rst

+2-1
Original file line numberDiff line numberDiff line change
@@ -2281,7 +2281,8 @@ Loading and running tests
22812281

22822282
The *testRunner* argument can either be a test runner class or an already
22832283
created instance of it. By default ``main`` calls :func:`sys.exit` with
2284-
an exit code indicating success or failure of the tests run.
2284+
an exit code indicating success (0) or failure (1) of the tests run.
2285+
An exit code of 5 indicates that no tests were run.
22852286

22862287
The *testLoader* argument has to be a :class:`TestLoader` instance,
22872288
and defaults to :data:`defaultTestLoader`.

Lib/test/test_unittest/test_program.py

+37-20
Original file line numberDiff line numberDiff line change
@@ -71,15 +71,22 @@ def testExpectedFailure(self):
7171
def testUnexpectedSuccess(self):
7272
pass
7373

74-
class FooBarLoader(unittest.TestLoader):
75-
"""Test loader that returns a suite containing FooBar."""
74+
class Empty(unittest.TestCase):
75+
pass
76+
77+
class TestLoader(unittest.TestLoader):
78+
"""Test loader that returns a suite containing the supplied testcase."""
79+
80+
def __init__(self, testcase):
81+
self.testcase = testcase
82+
7683
def loadTestsFromModule(self, module):
7784
return self.suiteClass(
78-
[self.loadTestsFromTestCase(Test_TestProgram.FooBar)])
85+
[self.loadTestsFromTestCase(self.testcase)])
7986

8087
def loadTestsFromNames(self, names, module):
8188
return self.suiteClass(
82-
[self.loadTestsFromTestCase(Test_TestProgram.FooBar)])
89+
[self.loadTestsFromTestCase(self.testcase)])
8390

8491
def test_defaultTest_with_string(self):
8592
class FakeRunner(object):
@@ -92,7 +99,7 @@ def run(self, test):
9299
runner = FakeRunner()
93100
program = unittest.TestProgram(testRunner=runner, exit=False,
94101
defaultTest='test.test_unittest',
95-
testLoader=self.FooBarLoader())
102+
testLoader=self.TestLoader(self.FooBar))
96103
sys.argv = old_argv
97104
self.assertEqual(('test.test_unittest',), program.testNames)
98105

@@ -108,7 +115,7 @@ def run(self, test):
108115
program = unittest.TestProgram(
109116
testRunner=runner, exit=False,
110117
defaultTest=['test.test_unittest', 'test.test_unittest2'],
111-
testLoader=self.FooBarLoader())
118+
testLoader=self.TestLoader(self.FooBar))
112119
sys.argv = old_argv
113120
self.assertEqual(['test.test_unittest', 'test.test_unittest2'],
114121
program.testNames)
@@ -118,7 +125,7 @@ def test_NonExit(self):
118125
program = unittest.main(exit=False,
119126
argv=["foobar"],
120127
testRunner=unittest.TextTestRunner(stream=stream),
121-
testLoader=self.FooBarLoader())
128+
testLoader=self.TestLoader(self.FooBar))
122129
self.assertTrue(hasattr(program, 'result'))
123130
out = stream.getvalue()
124131
self.assertIn('\nFAIL: testFail ', out)
@@ -130,13 +137,13 @@ def test_NonExit(self):
130137

131138
def test_Exit(self):
132139
stream = BufferedWriter()
133-
self.assertRaises(
134-
SystemExit,
135-
unittest.main,
136-
argv=["foobar"],
137-
testRunner=unittest.TextTestRunner(stream=stream),
138-
exit=True,
139-
testLoader=self.FooBarLoader())
140+
with self.assertRaises(SystemExit) as cm:
141+
unittest.main(
142+
argv=["foobar"],
143+
testRunner=unittest.TextTestRunner(stream=stream),
144+
exit=True,
145+
testLoader=self.TestLoader(self.FooBar))
146+
self.assertEqual(cm.exception.code, 1)
140147
out = stream.getvalue()
141148
self.assertIn('\nFAIL: testFail ', out)
142149
self.assertIn('\nERROR: testError ', out)
@@ -147,12 +154,11 @@ def test_Exit(self):
147154

148155
def test_ExitAsDefault(self):
149156
stream = BufferedWriter()
150-
self.assertRaises(
151-
SystemExit,
152-
unittest.main,
153-
argv=["foobar"],
154-
testRunner=unittest.TextTestRunner(stream=stream),
155-
testLoader=self.FooBarLoader())
157+
with self.assertRaises(SystemExit):
158+
unittest.main(
159+
argv=["foobar"],
160+
testRunner=unittest.TextTestRunner(stream=stream),
161+
testLoader=self.TestLoader(self.FooBar))
156162
out = stream.getvalue()
157163
self.assertIn('\nFAIL: testFail ', out)
158164
self.assertIn('\nERROR: testError ', out)
@@ -161,6 +167,17 @@ def test_ExitAsDefault(self):
161167
'expected failures=1, unexpected successes=1)\n')
162168
self.assertTrue(out.endswith(expected))
163169

170+
def test_ExitEmptySuite(self):
171+
stream = BufferedWriter()
172+
with self.assertRaises(SystemExit) as cm:
173+
unittest.main(
174+
argv=["empty"],
175+
testRunner=unittest.TextTestRunner(stream=stream),
176+
testLoader=self.TestLoader(self.Empty))
177+
self.assertEqual(cm.exception.code, 5)
178+
out = stream.getvalue()
179+
self.assertIn('\nNO TESTS RAN\n', out)
180+
164181

165182
class InitialisableProgram(unittest.TestProgram):
166183
exit = False

Lib/test/test_unittest/test_result.py

+1
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,7 @@ def testFailFastSetByRunner(self):
451451
stream = BufferedWriter()
452452
runner = unittest.TextTestRunner(stream=stream, failfast=True)
453453
def test(result):
454+
result.testsRun += 1
454455
self.assertTrue(result.failfast)
455456
result = runner.run(test)
456457
stream.flush()

Lib/test/test_unittest/test_runner.py

+10
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,16 @@ def test(self):
577577
'inner setup', 'inner test', 'inner cleanup',
578578
'end outer test', 'outer cleanup'])
579579

580+
def test_run_empty_suite_error_message(self):
581+
class EmptyTest(unittest.TestCase):
582+
pass
583+
584+
suite = unittest.defaultTestLoader.loadTestsFromTestCase(EmptyTest)
585+
runner = getRunner()
586+
runner.run(suite)
587+
588+
self.assertIn("\nNO TESTS RAN\n", runner.stream.getvalue())
589+
580590

581591
class TestModuleCleanUp(unittest.TestCase):
582592
def test_add_and_do_ModuleCleanup(self):

Lib/unittest/main.py

+8-1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .signals import installHandler
1010

1111
__unittest = True
12+
_NO_TESTS_EXITCODE = 5
1213

1314
MAIN_EXAMPLES = """\
1415
Examples:
@@ -279,6 +280,12 @@ def runTests(self):
279280
testRunner = self.testRunner
280281
self.result = testRunner.run(self.test)
281282
if self.exit:
282-
sys.exit(not self.result.wasSuccessful())
283+
if self.result.testsRun == 0:
284+
sys.exit(_NO_TESTS_EXITCODE)
285+
elif self.result.wasSuccessful():
286+
sys.exit(0)
287+
else:
288+
sys.exit(1)
289+
283290

284291
main = TestProgram

Lib/unittest/runner.py

+2
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,8 @@ def run(self, test):
274274
infos.append("failures=%d" % failed)
275275
if errored:
276276
infos.append("errors=%d" % errored)
277+
elif run == 0:
278+
self.stream.write("NO TESTS RAN")
277279
else:
278280
self.stream.write("OK")
279281
if skipped:

Misc/ACKS

+1
Original file line numberDiff line numberDiff line change
@@ -1513,6 +1513,7 @@ Vlad Riscutia
15131513
Wes Rishel
15141514
Daniel Riti
15151515
Juan M. Bello Rivas
1516+
Stefano Rivera
15161517
Llandy Riveron Del Risco
15171518
Mohd Sanad Zaki Rizvi
15181519
Davide Rizzo
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
The :mod:`unittest` runner will now exit with status code 5 if no tests
2+
were run. It is common for test runner misconfiguration to fail to find
3+
any tests, this should be an error.

0 commit comments

Comments
 (0)