Python: test for unraisable exceptions with unittest.mock

Advanced testing apparatus.

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:

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.

Update (2025-01-09): Added the below admonitions, thanks to Tom Grainger for pointing out the issues.

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.

Fin

Buckle up now for one bumpy ride, Unraisable exceptions, you can’t hide!

—Adam


Learn how to make your tests run quickly in my book Speed Up Your Django Tests.


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: