Python: test for unraisable exceptions with unittest.mock

A few days ago, I blogged about debugging unraisable exceptions with Rich. Here’s a sequel on testing that some block of code doesn’t trigger any unraisable exceptions.
An unraisable exception is one that occurs outside the normal execution flow, so Python logs it as “unraisable” and stops propagation. The logging comes from sys.unraisablehook
, a function that you can swap to change behaviour. The test technique below temporarily swaps unraisablehook
with a mock that counts calls.
Here’s the context manager I came up with to assert that no unraisable exceptions occur within its block:
import sys
from contextlib import contextmanager
from unittest import mock
@contextmanager
def assert_no_unraisable_exceptions():
"""
Check that the wrapped block of code does not trigger any unraisable
exceptions. They will still be logged via the pre-existing hook.
https://adamj.eu/tech/2025/01/07/python-test-unraisable-exceptions/
"""
num_unraisable = 0
def unraisablehook(*args, **kwargs):
nonlocal num_unraisable
num_unraisable += 1
sys.__unraisablehook__(*args, **kwargs)
with mock.patch.object(sys, "unraisablehook", new=unraisablehook):
yield
assert num_unraisable == 0, "No unraisable exceptions should have occurred"
Note:
contextmanager
turnsassert_no_unraisable_exceptions()
from a generator function into a combo context manager/decorator.- The function defines a temporary
unraisablehook()
function that increments a counter and then calls the original hook. In the typical case, this means that Python still logs any encountered unraisable exceptions. unittest.mock.patch.object()
replacessys.unraisablehook
with the new function for the duration of its block. It wraps theyield
, within which the code under test runs.- After the block, the function asserts that the counter is still zero, meaning no unraisable exceptions occurred. It uses a pytest-style plain
assert
, but this will also work fine with unittest. The important output is from Python’s default unraisable hook.
Here’s an example class to test:
class HamsterRun:
def __del__(self):
pass
And a test that its __del__
method doesn’t trigger any exceptions:
from unittest import TestCase
from example import HamsterRun
from testing import assert_no_unraisable_exceptions
class HamsterRunTests(TestCase):
def test_deletion_doesnt_raise(self):
with assert_no_unraisable_exceptions():
hamster_run = HamsterRun()
del hamster_run
This passes with unittest:
$ python -m unittest test_example
.
----------------------------------------------------------------------
Ran 1 test in 0.000s
OK
Now let’s alter HamsterRun
to raise an exception the quick way in its __del__
method:
class HamsterRun:
def __del__(self):
1 / 0
Now, we can see the unraisable exception and our context manager fails the test:
$ python -m unittest test_example
Exception ignored in: <function HamsterRun.__del__ at 0x104ca9f80>
Traceback (most recent call last):
File "/.../example.py", line 3, in __del__
1 / 0
~~^~~
ZeroDivisionError: division by zero
F
======================================================================
FAIL: test_deletion_doesnt_raise (test_example.HamsterRunTests.test_deletion_doesnt_raise)
----------------------------------------------------------------------
Traceback (most recent call last):
File "/.../test_example.py", line 9, in test_deletion_doesnt_raise
with assert_no_unraisable_exceptions():
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/.../contextlib.py", line 144, in __exit__
next(self.gen)
File "/.../testing.py", line 19, in assert_no_unraisable_exceptions
assert num_unraisable == 0, "No unraisable exceptions should have occurred"
^^^^^^^^^^^^^^^^^^^
AssertionError: No unraisable exceptions should have occurred
----------------------------------------------------------------------
Ran 1 test in 0.001s
FAILED (failures=1)
We first see the “Exception ignored in” message from Python’s default unraisable hook. Then, the test fails, and we can see the assertion message from our context manager.
Note that this custom hook may capture unraisable exceptions from any thread, not just the one running the test. If your system uses threads, you’ll need to take this into account.
Also, if your test relies on garbage collection of reference cycles, you may need to run gc.collect()
several times within the context manager’s block.
Learn how to make your tests run quickly in my book Speed Up Your Django Tests.
One summary email a week, no spam, I pinky promise.
Related posts:
- Python: debug unraisable exceptions with Rich
- Python: Fail in three characters with
1/0
- Python: spy for changes with
sys.monitoring
Tags: python