
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.

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!
