Python Exception Testing: Clean and Effective Methods | by Naomi Kriger | Jul, 2023


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:

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



Source link

Leave a Comment