Skip to content

gh-62965: Add debugging support to unittest (--debug --pm --pdb --trace) #99169

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 37 additions & 1 deletion Doc/library/unittest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,29 @@ Command-line options

See `Signal Handling`_ for the functions that provide this functionality.

.. cmdoption:: --debug

On test fail or error the test run terminates immediately with original
exception, similar to normal script execution. Useful for seamless external
post-mortem handling. Delayed teardowns are run automatically when the raised
exception and traceback are recycled; or explicitely before when
``exception.pm_teardown()`` -- a method added to the exception -- is called.

.. cmdoption:: --pdb

Runs :func:`pdb.post_mortem` upon each error. Short for ``--pm=pdb``

.. cmdoption:: --pm=<debugger>

Run custom post-mortem debugger (module or class) upon each error using its
``post_mortem()`` function or method.
Examples: ``--pm=pywin.debugger``, ``--pm=IPython.terminal.debugger.Pdb``

.. cmdoption:: --trace

Break at the beginning of each test using :mod:`pdb` or the debugger set by
:option:`--pm` via its ``runcall()`` function or method.

.. cmdoption:: -f, --failfast

Stop the test run on the first error or failure.
Expand Down Expand Up @@ -253,6 +276,9 @@ Command-line options
.. versionadded:: 3.7
The command-line option ``-k``.

.. versionadded:: 3.12
The command-line options ``--debug``, ``--pdb`` and ``--pm``.

The command line can also be used for test discovery, for running all of the
tests in a project or just a subset.

Expand Down Expand Up @@ -2228,7 +2254,8 @@ Loading and running tests

.. function:: main(module='__main__', defaultTest=None, argv=None, testRunner=None, \
testLoader=unittest.defaultTestLoader, exit=True, verbosity=1, \
failfast=None, catchbreak=None, buffer=None, warnings=None)
failfast=None, catchbreak=None, buffer=None, warnings=None, \
debug=False)

A command-line program that loads a set of tests from *module* and runs them;
this is primarily for making test modules conveniently executable.
Expand Down Expand Up @@ -2279,6 +2306,12 @@ Loading and running tests
Calling ``main`` actually returns an instance of the ``TestProgram`` class.
This stores the result of the tests run as the ``result`` attribute.

When *debug* is ``True`` (corresponding to :option:`!--debug`) execution terminates
with original exception upon the first error. When *debug* is the name of a
debugger module or class - for example ":mod:`pdb`" - then post-mortem debugging is
run upon each error.


.. versionchanged:: 3.1
The *exit* parameter was added.

Expand All @@ -2290,6 +2323,9 @@ Loading and running tests
The *defaultTest* parameter was changed to also accept an iterable of
test names.

.. versionchanged:: 3.12
The *debug* parameter was added.


load_tests Protocol
###################
Expand Down
1 change: 1 addition & 0 deletions Lib/test/test_unittest/test_break.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ def __init__(self, catchbreak):
self.failfast = failfast
self.catchbreak = catchbreak
self.tb_locals = False
self.debug = False
self.testRunner = FakeRunner
self.test = test
self.result = None
Expand Down
1 change: 1 addition & 0 deletions Lib/test/test_unittest/test_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -1299,6 +1299,7 @@ def testAssertRaisesNoExceptionType(self):
with self.assertRaises(TypeError):
self.assertRaises((ValueError, object))

@support.refcount_test
def testAssertRaisesRefcount(self):
# bpo-23890: assertRaises() must not keep objects alive longer
# than expected
Expand Down
151 changes: 147 additions & 4 deletions Lib/test/test_unittest/test_program.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import os
import sys
import io
import traceback
import gc
import subprocess
from test import support
import unittest
import unittest.mock
import test.test_unittest
from test.test_unittest.test_result import BufferedWriter

Expand Down Expand Up @@ -31,7 +35,7 @@ def testNoExit(self):
test = object()

class FakeRunner(object):
def run(self, test):
def run(self, test, debug=False):
self.test = test
return result

Expand Down Expand Up @@ -83,7 +87,7 @@ def loadTestsFromNames(self, names, module):

def test_defaultTest_with_string(self):
class FakeRunner(object):
def run(self, test):
def run(self, test, debug=False):
self.test = test
return True

Expand All @@ -98,7 +102,7 @@ def run(self, test):

def test_defaultTest_with_iterable(self):
class FakeRunner(object):
def run(self, test):
def run(self, test, debug=False):
self.test = test
return True

Expand Down Expand Up @@ -161,13 +165,139 @@ def test_ExitAsDefault(self):
'expected failures=1, unexpected successes=1)\n')
self.assertTrue(out.endswith(expected))

class TestRaise(unittest.TestCase):
td_log = ''
class Error(Exception):
pass
def test_raise(self):
self = self
raise self.Error
def setUp(self):
self.addCleanup(self.clInstance)
self.addClassCleanup(self.clClass)
unittest.case.addModuleCleanup(self.clModule)
def tearDown(self):
__class__.td_log += 't'
1 / 0 # should not block further cleanups
def clInstance(self):
__class__.td_log += 'c'
@classmethod
def tearDownClass(cls):
__class__.td_log += 'T'
@classmethod
def clClass(cls):
__class__.td_log += 'C'
2 / 0
@staticmethod
def clModule():
__class__.td_log += 'M'

class TestRaiseLoader(unittest.TestLoader):
def loadTestsFromModule(self, module):
return self.suiteClass(
[self.loadTestsFromTestCase(Test_TestProgram.TestRaise)])

def loadTestsFromNames(self, names, module):
return self.suiteClass(
[self.loadTestsFromTestCase(Test_TestProgram.TestRaise)])

def test_debug(self):
self.TestRaise.td_log = ''
try:
unittest.main(
argv=["TestRaise", "--debug"],
testRunner=unittest.TextTestRunner(stream=io.StringIO()),
testLoader=self.TestRaiseLoader())
except self.TestRaise.Error as e:
# outer pm handling of original exception
assert self.TestRaise.td_log == '' # still set up!
else:
self.fail("TestRaise not raised")
# test delayed automatic teardown after leaving outer exception
# handling. Note, the explicit e.pm_teardown() variant is tested below
# in test_inline_debugging().
if not hasattr(sys, 'getrefcount'):
# PyPy etc.
gc.collect()
assert self.TestRaise.td_log == 'tcTCM', self.TestRaise.td_log

def test_no_debug(self):
self.assertRaises(
SystemExit,
unittest.main,
argv=["TestRaise"],
testRunner=unittest.TextTestRunner(stream=io.StringIO()),
testLoader=self.TestRaiseLoader())

def test_inline_debugging(self):
from test.test_pdb import PdbTestInput
# post-mortem --pdb
out, err = io.StringIO(), io.StringIO()
try:
with unittest.mock.patch('sys.stdout', out),\
unittest.mock.patch('sys.stderr', err),\
PdbTestInput(['c'] * 3):
unittest.main(
argv=["TestRaise", "--pdb"],
testRunner=unittest.TextTestRunner(stream=err),
testLoader=self.TestRaiseLoader())
except SystemExit:
assert '-> raise self.Error\n(Pdb)' in out.getvalue(), 'out:' + out.getvalue()
assert '-> 2 / 0\n(Pdb)' in out.getvalue(), 'out:' + out.getvalue()
assert 'FAILED (errors=3)' in err.getvalue(), 'err:' + err.getvalue()
else:
self.fail("SystemExit not raised")

# post-mortem --pm=<DebuggerClass>, early user debugger quit
out, err = io.StringIO(), io.StringIO()
self.TestRaise.td_log = ''
try:
with unittest.mock.patch('sys.stdout', out),\
unittest.mock.patch('sys.stderr', err),\
PdbTestInput(['q']):
unittest.main(
argv=["TestRaise", "--pm=pdb.Pdb"],
testRunner=unittest.TextTestRunner(stream=err),
testLoader=self.TestRaiseLoader())
except unittest.case.DebuggerQuit as e:
assert e.__context__.__class__ == self.TestRaise.Error
assert self.TestRaise.td_log == '' # still set up!
assert out.getvalue().endswith('-> raise self.Error\n(Pdb) q\n'), 'out:' + out.getvalue()
# test explicit pm teardown variant.
e.pm_teardown()
assert self.TestRaise.td_log == 'tcTCM', self.TestRaise.td_log
assert e.pm_teardown.result.testsRun == 1
e_hold = e # noqa
else:
self.fail("DebuggerQuit not raised")
# delayed teardowns must not be repeated
e_hold.pm_teardown()
del e_hold
gc.collect()
assert self.TestRaise.td_log == 'tcTCM', self.TestRaise.td_log

# --trace
out, err = io.StringIO(), io.StringIO()
try:
with unittest.mock.patch('sys.stdout', out), PdbTestInput(['c']):
unittest.main(
argv=["TestRaise", "--trace"],
testRunner=unittest.TextTestRunner(stream=err),
testLoader=self.TestRaiseLoader())
except SystemExit:
assert '-> self = self\n(Pdb)' in out.getvalue(), 'out:' + out.getvalue()
assert 'FAILED (errors=3)' in err.getvalue(), 'err:' + err.getvalue()
else:
self.fail("SystemExit not raised")


class InitialisableProgram(unittest.TestProgram):
exit = False
result = None
verbosity = 1
defaultTest = None
tb_locals = False
debug = False
testRunner = None
testLoader = unittest.defaultTestLoader
module = '__main__'
Expand All @@ -189,7 +319,7 @@ def __init__(self, **kwargs):
FakeRunner.raiseError -= 1
raise TypeError

def run(self, test):
def run(self, test, debug=False):
FakeRunner.test = test
return RESULT

Expand Down Expand Up @@ -322,6 +452,19 @@ def test_locals(self):
'verbosity': 1,
'warnings': None})

def test_debug(self):
program = self.program
program.testRunner = FakeRunner
program.parseArgs([None, '--debug'])
self.assertTrue(program.debug)
program.parseArgs([None, '--pdb'])
self.assertTrue(program.pdb)
program.parseArgs([None, '--pm=pdb'])
self.assertEqual(program.pm, 'pdb')
program.parseArgs([None, '--trace'])
self.assertTrue(program.trace)


def testRunTestsOldRunnerClass(self):
program = self.program

Expand Down
Loading