The world’s leading publication for data science, AI, and ML professionals.

Python Exception Testing: Clean and Effective Methods

Beyond Basics: Advanced Python Exception Testing for Pytest and Unittest

image by chenspec on pixabay
image by chenspec on pixabay

Testing exceptions is more than just a formality – it’s an essential part of writing reliable code. In this tutorial, we will explore practical and effective methods to test Python code that raises and does not raise exceptions, verifying the accuracy of the exception messages, and covering both pytest and unittest, with and without parameterized tests for each framework.

By the end of this tutorial, you will have a solid understanding of how to write clean, efficient, and informative exception tests for your code.


Let’s look into the following example:

def divide(num_1: float, num_2: float) -> float:
    if not isinstance(num_1, (int, float)) 
            or not isinstance(num_2, (int, float)):
        raise TypeError("at least one of the inputs "
                        f"is not a number: {num_1}, {num_2}")

    return num_1 / num_2

There are several flows we can test for the function above – happy flow, a zero denominator, and a non-digit input.

Now, let’s see what such tests would look like, using pytest:

pytest

from contextlib import nullcontext as does_not_raise

import pytest

from operations import divide

def test_happy_flow():
    with does_not_raise():
        assert divide(30, 2.5) is not None
    assert divide(30, 2.5) == 12.0

def test_division_by_zero():
    with pytest.raises(ZeroDivisionError) as exc_info:
        divide(10.5, 0)
    assert exc_info.value.args[0] == "float division by zero"

def test_not_a_digit():
    with pytest.raises(TypeError) as exc_info:
        divide("a", 10.5)
    assert exc_info.value.args[0] == 
           "at least one of the inputs is not a number: a, 10.5"

We can also perform a sanity check to see what happens when we test an invalid flow against the wrong exception type or when we attempt to check for a raised exception in a happy flow. In these cases, the tests will fail:

# Both tests below should fail

def test_wrong_exception():
    with pytest.raises(TypeError) as exc_info:
        divide(10.5, 0)
    assert exc_info.value.args[0] == "float division by zero"

def test_unexpected_exception_in_happy_flow():
    with pytest.raises(Exception):
        assert divide(30, 2.5) is not None

So, why did the tests above fail? The with context catches the specific type of exception requested and verifies that the exception type is indeed the one we asked for.

In test_wrong_exception_check, an exception (ZeroDivisionError) was thrown, but it wasn’t caught by TypeError. Therefore, in the stack trace, we’ll see ZeroDivisionError was thrown and wasn’t caught by the TypeError context.

In test_redundant_exception_context our with pytest.raises context attempted to validate the requested exception type (we provided Exception in this case) but since no exception was thrown – the test failed with the message Failed: DID NOT RAISE <class 'Exception'>.

Now, moving on to the next stage, let’s explore how we can make our tests much more concise and cleaner by using parametrize.

Parametrize

from contextlib import nullcontext as does_not_raise

import pytest

from operations import divide

@pytest.mark.parametrize(
    "num_1, num_2, expected_result, exception, message",
    [
        (30, 2.5, 12.0, does_not_raise(), None),

        (10.5, 0, None, pytest.raises(ZeroDivisionError),
         "float division by zero"),

        ("a", 10.5, None, pytest.raises(TypeError),
         "at least one of the inputs is not a number: a, 10.5")

    ],
    ids=["valid inputs",
         "divide by zero",
         "not a number input"]
)
def test_division(num_1, num_2, expected_result, exception, message):
    with exception as e:
        result = divide(num_1, num_2)
    assert message is None or message in str(e)
    if expected_result is not None:
        assert result == expected_result

The ids parameter changes the test-case name displayed on the IDE’s test-bar view. In the screenshot below we can see it in action: with ids on the left, and without ids on the right.

screenshot by author
screenshot by author

Now that we’ve covered pytest framework, let’s see how to write the same tests using unittest.

unittest

from unittest import TestCase

from operations import divide

class TestDivide(TestCase):
    def test_happy_flow(self):
        result = divide(0, 10.5)
        self.assertEqual(result, 0)

    def test_division_by_zero(self):
        with self.assertRaises(ZeroDivisionError) as context:
            divide(10, 0)
        self.assertEqual(context.exception.args[0], "division by zero")

    def test_not_a_digit(self):
        with self.assertRaises(TypeError) as context:
            divide(10, "c")
        self.assertEqual(context.exception.args[0],
                         "at least one of the inputs "
                         "is not a number: 10, c")

If we want to use parameterized with unittest we need to install the package. Let’s see parametrized tests in unittest would look like:

Parametrized

import unittest

from parameterized import parameterized  # requires installation

from operations import divide

def get_test_case_name(testcase_func, _, param):
    test_name = param.args[-1]
    return f"{testcase_func.__name__}_{test_name}"

class TestDivision(unittest.TestCase):

    @parameterized.expand([
        (30, 2.5, 12.0, None, None, "valid inputs"),
        (10.5, 0, None, ZeroDivisionError,
         "float division by zero", "divide by zero"),
        ("a", 10.5, None, TypeError,
         "at least one of the inputs is not a number: a, 10.5",
         "not a number input")
    ], name_func=get_test_case_name)
    def test_division(self, num_1, num_2, expected_result, exception_type,
                      exception_message, test_name):
        with self.subTest(num_1=num_1, num_2=num_2):
            if exception_type is not None:
                with self.assertRaises(exception_type) as e:
                    divide(num_1, num_2)
                self.assertEqual(str(e.exception), exception_message)
            else:
                result = divide(num_1, num_2)
                self.assertIsNotNone(result)
                self.assertEqual(result, expected_result)

In unittest, we also modified the test case names, similar to the pytest example above. However, to achieve this, we utilized the name_func parameter along with a custom function.


In conclusion, today we explored effective methods for Testing Python exceptions. We learned about recognizing when an exception is thrown as expected and verifying that the exception message matches our expectations. We examined various ways to test a divide function, including the traditional approach using pytest and a cleaner approach with parametrize. We also explored the unittest equivalent with parameterized, which required installing the library, as well as without it. The use of ids and custom test names provided a cleaner and more informative view in the IDE’s test bar, making it easier to understand and navigate the test cases. By using these techniques, we can improve our unit tests and ensure that our code handles exceptions appropriately.

Happy testing!

image by jakob5200 on pixabay
image by jakob5200 on pixabay

Related Articles