How To Test Python Exception Handling Using Pytest Assert (A Simple Guide)

As software engineers, handling errors is an important part of code development.

How often do users behave unexpectedly? More often than not. In most respect, people, systems and the Universe are random.

Maybe your code expects user input or takes data from a received packet and transforms it, perhaps performing complex calculations.

Network delays, even reordering or corrupted data. Whatever be it, it’s good to plan for the worst.

Handling exceptions enables graceful error handling and prevents abrupt program termination.

It allows you to catch and handle unexpected errors, improving the stability and reliability of your code.

By handling exceptions, you can ensure that your code is robust and can gracefully recover from errors, enhancing the overall user experience.

Moreover, exception handling is vital for security purposes, as it helps prevent sensitive information from being exposed and protects against malicious attacks.

But, with more exception handling comes challenges in testing.

How do you handle unpredictability in code execution and flow? What about error propagation to the rest of the stack?

Do you raise errors or handle them? And how do you ensure to display the correct and helpful type of error? This is very important, particularly when dealing with User facing tooling like APIs and response codes.

Pytest assert exception handling can help with this.

In this article, we’ll look at how to test basic exceptions, and use Pytest’s excinfo properties to access exception messages.

We’ll look at handling failing cases, no exception raised cases, custom exceptions, and even multiple assertions in the same test.

Let’s get into it.

Link To GitHub Repo

Understanding Exception Testing

Exception handling plays a vital role, encompassing the significance of managing errors and exceptional conditions effectively.

Python’s exception handling mechanism allows you to handle various types of exceptions that can occur during program execution, such as ValueError, TypeError, FileNotFoundError, and ZeroDivisionError, among others.

By utilizing try-except blocks, you can gracefully handle exceptions and prevent program crashes.

Exception handling also allows for graceful degradation, where alternative code paths can be executed when specific exceptions occur, enabling the program to recover or continue execution without disruption.

When handling exceptions, it’s essential to provide meaningful error messages and facilitate the debugging process.

In this article, we’ll learn how to test various exceptions using Pytest assert exception capability.

Objectives

By the end of this tutorial you should be able to:

  • Have a foundational understanding of exception handling
  • Test the various exceptions that your code may raise or handle using Pytest assert exception capability
  • Understand how to compare messages raised by the exceptions
  • Test custom exceptions handling in Pytest, including multiple assertions in the same test

Project Set Up

Getting Started

The project has the following structure

pytest-assert-exception-repo

Prerequisites

To easily follow along and achieve the above objectives, the following is recommended as a basic requirement

  • Basic knowledge of the Python programming language
  • Some basics of try/except blocks and exception handling

To get started, clone the repo here, or you can create your own repo by creating a folder and running git init to initialise it.

In this project, we’ll be using Python 3.11.3.

Create a virtual environment and install the requirements (packages) using

1
pip install -r requirements.txt

Test Example Code

For this article, we’ll look at a few simple pieces of code that do some basic operations.

src/assert_examples.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import os  
import math
import re


class InvalidEmailError(Exception):
"""
Raised when an email address is invalid
"""

pass


def division(a: int | float, b: int | float) -> float | ZeroDivisionError:
"""
Returns the result of dividing a by b

Raises:
ZeroDivisionError: If b is 0
"""
try:
return a / b
except ZeroDivisionError:
raise ZeroDivisionError("Division by zero is not allowed")


def square_root(a: int) -> float | ValueError:
"""
Returns the square root of a

Raises:
ValueError: If a is negative
"""
try:
return math.sqrt(a)
except ValueError:
raise ValueError("Square root of negative numbers is not allowed")


def delete_file(filename: str) -> None | FileNotFoundError:
"""
Deletes a file

Raises:
FileNotFoundError: If the file does not exist
"""
try:
os.remove(filename)
except FileNotFoundError:
raise FileNotFoundError(f"File {filename} not found")


def validate_email(email: str) -> bool | InvalidEmailError:
"""
Validates an email address

Raises:
InvalidEmailError: If the email address is invalid
"""
if re.match(r"[^@]+@[^@]+\.[^@]+", email):
return True
else:
raise InvalidEmailError("Invalid email address")

The above code contains 5 functions, each performing a simple task and handling one key exception.

Note the use of the InvalidEmailError Custom exception at the top, called by the validate_email() function.

We’ll review Custom Exception Handling later in the article.

Using Pytest Asserts To Assert Exceptions

Now let’s see how we can use Pytest to test the various exceptions.

tests/unit/test_assert_exceptions.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import pytest  
from src.assert_examples import (
division,
square_root,
validate_email,
delete_file,
InvalidEmailError,
)


def test_division_zero_division_error():
"""
Test that a ZeroDivisionError is raised when the second argument is 0
"""
with pytest.raises(ZeroDivisionError) as excinfo:
division(1, 0)
assert str(excinfo.value) == "Division by zero is not allowed"


def test_square_root_value_error():
"""
Test that a ValueError is raised when the argument is negative
"""
with pytest.raises(ValueError) as excinfo:
square_root(-1)
assert str(excinfo.value) == "Square root of negative numbers is not allowed"


def test_delete_file_not_found_error():
"""
Test that a FileNotFoundError is raised when the file does not exist
"""
with pytest.raises(FileNotFoundError) as excinfo:
delete_file("non_existent_file.txt")
assert str(excinfo.value) == "File non_existent_file.txt not found"


def test_validate_email_value_error():
"""
Test that an InvalidEmailError is raised when the email address is invalid
"""
with pytest.raises(InvalidEmailError) as excinfo:
validate_email("invalid_email")
assert str(excinfo.value) == "Invalid email address"

Exceptions can be tested in pytest using the with pytest.raises(EXCEPTION): block syntax.

You then place the code within the block and if your code raises an exception, pytest will pass. Or fail if it doesn’t raise an exception.

In the above code, we’ve raised and tested

  • ZeroDivisionError
  • ValueError
  • FileNotFoundError
  • InvalidEmailError (Custom Exception)

Running The Tests

To run the tests, simply run pytest in your terminal or you can provide the path to the file as an argument. Use -v for verbose logging.

1
pytest -v

pytest-assert-exception-run-test

Matching Assert Exception Messages and Excinfo Object

If you like, you can use the following simple block to assert the exception

tests/unit/test_assert_exceptions.py

1
2
with pytest.raises(FileNotFoundError):  
delete_file("non_existent_file.txt")

In these examples, we have captured the output in the excinfo object which allows us to assert the actual exception message too.

So the following block of code, not only checks that a FileNotFoundError is raised but also asserts our expected message — File non_existent_file.txt not found, which is equally important.

tests/unit/test_assert_exceptions.py

1
2
3
4
5
6
7
def test_delete_file_not_found_error():  
"""
Test that a FileNotFoundError is raised when the file does not exist
"""
with pytest.raises(FileNotFoundError) as excinfo:
delete_file("non_existent_file.txt")
assert str(excinfo.value) == "File non_existent_file.txt not found"

You can see how handy this is.

How To Assert That NO Exception Is Raised

We’ve just seen how to assert when an exception is raised.

But what about the other side of the equation? If we wanna test that No Exception is Raised?

We can do it using Pytest xfail .

If you need a brush up on Pytest Xfail, we’ve written an Ultimate Guide To Using Pytest Skip Test And XFail teaching you to conveniently skip or fail a test intentionally.

tests/unit/test_assert_exceptions.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def test_division_no_exception_raised():  
"""
Test that no exception is raised when the second argument is not 0
"""
try:
division(1, 1)
except Exception as excinfo:
pytest.fail(f"Unexpected exception raised: {excinfo}")


def test_square_root_no_exception_raised():
"""
Test that no exception is raised when the argument is not negative
"""
try:
square_root(1)
except Exception as excinfo:
pytest.fail(f"Unexpected exception raised: {excinfo}")

In the above code, we run the desired function in a try/except block, catch any unexpected exceptions and fail the test if an exception is raised.

This acts as a failsafe and allows us to proceed with confidence that our code works as expected.

Asserting Custom Exceptions

We’ve seen a few cases of asserting inbuilt exceptions using Pytest. But what about defining and testing your own exceptions.

In Python, we can define our own custom exceptions in the following way.

src/assert_examples.py

1
2
3
4
5
6
class InvalidEmailError(Exception):  
"""
Raised when an email address is invalid
"""

pass

You can choose where to handle it, ideally when the exception is raised.

src/assert_examples.py

1
2
3
4
5
6
7
8
9
10
11
def validate_email(email: str) -> bool | InvalidEmailError:  
"""
Validates an email address

Raises:
InvalidEmailError: If the email address is invalid
"""
if re.match(r"[^@]+@[^@]+\.[^@]+", email):
return True
else:
raise InvalidEmailError("Invalid email address")

In the above case, we’ve just raised the exception, but you can do whatever you like here.

Now testing this is very straightforward and exactly the same as testing an inbuilt exception.

tests/unit/test_assert_exceptions.py

1
2
3
4
5
6
7
def test_validate_email_value_error():  
"""
Test that an InvalidEmailError is raised when the email address is invalid
"""
with pytest.raises(InvalidEmailError) as excinfo:
validate_email("invalid_email")
assert str(excinfo.value) == "Invalid email address"

Just make sure to import your Custom Exception Class into your test file.

Multiple Assertions In The Same Test

The last bit we’ll learn in this article is how to do multiple assertions within the same test.

Maybe you have several conditions you want to check to prove that your test works, Pytest is great at this.

tests/unit/test_assert_exceptions.py

1
2
3
4
5
6
7
def test_square_root_division_multiple_exceptions():  
"""
Test that multiple exceptions can be asserted in a single test
"""
with pytest.raises((ValueError, ZeroDivisionError)) as excinfo:
square_root(-1)
assert str(excinfo.value) == "Square root of negative numbers is not allowed"

Here we are testing that our code raised either one of ValueError or ZeroDivisionError AND that the error message is expected.

You can include as many assertions as you please and Pytest will check each of them.

Conclusion

In this article, we learnt the importance of good exception handling and how to test it using Pytest’s assert exception capability.

We looked at a simple example and syntax for various types of exceptions — Inbuilt and Custom.

You also learnt about excinfo and how to assert the returned exception message and that no exception is raised for correct input, using Pytest xfail .

Lastly, we reviewed testing multiple exceptions in the same test. All of this should give you a strong base and confidence to write quality code that catches exceptions and handles them gracefully.

If you have any ideas for improvement or like me to cover any topics please comment below or send me a message via Twitter, GitHub or Email.

Till the next time… Cheers!

Additional Reading

How to write and report assertions in tests - pytest documentation