Checking exceptions: pytest.raises

A good API documentation will clearly explain what the purpose of each function is, its parameters, and return values. Great API documentation also clearly explains which exceptions are raised and when.

For that reason, testing that exceptions are raised in the appropriate circumstances is just as important as testing the main functionality of APIs. It is also important to make sure that exceptions contain an appropriate and clear message to help users understand the issue.

Suppose we are writing an API for a game. This API allows programmers to write mods, which are a plugin of sorts that can change several aspects of a game, from new textures to complete new story lines and types of characters.

This API has a function that allows mod writers to create a new character, and it can raise exceptions in some situations:

def create_character(name: str, class_name: str) -> Character:
"""
Creates a new character and inserts it into the database.

:raise InvalidCharacterNameError:
if the character name is empty.

:raise InvalidClassNameError:
if the class name is invalid.

:return: the newly created Character.
"""
...

Pytest makes it easy to check that your code is raising the proper exceptions with the raises statement:

def test_empty_name():
with pytest.raises(InvalidCharacterNameError):
create_character(name='', class_name='warrior')


def test_invalid_class_name():
with pytest.raises(InvalidClassNameError):
create_character(name='Solaire', class_name='mage')

pytest.raises is a with-statement that ensures the exception class passed to it will be raised inside its execution block. For more details (https://docs.python.org/3/reference/compound_stmts.html#the-with-statement). Let's see how create_character implements those checks:

def create_character(name: str, class_name: str) -> Character:
"""
Creates a new character and inserts it into the database.
...
"""
if not name:
raise InvalidCharacterNameError('character name empty')

if class_name not in VALID_CLASSES:
msg = f'invalid class name: "{class_name}"'
raise InvalidCharacterNameError(msg)
...

If you are paying close attention, you probably noticed that the copy-paste error in the preceding code should actually raise an  InvalidClassNameError for the class name check.

Executing this file:

======================== test session starts ========================
...
collected 2 items

tests\test_checks.py .F [100%]

============================= FAILURES ==============================
______________________ test_invalid_class_name ______________________

def test_invalid_class_name():
with pytest.raises(InvalidCharacterNameError):
> create_character(name='Solaire', class_name='mage')

tests\test_checks.py:51:
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

name = 'Solaire', class_name = 'mage'

def create_character(name: str, class_name: str) -> Character:
"""
Creates a new character and inserts it into the database.

:param name: the character name.

:param class_name: the character class name.

:raise InvalidCharacterNameError:
if the character name is empty.

:raise InvalidClassNameError:
if the class name is invalid.

:return: the newly created Character.
"""
if not name:
raise InvalidCharacterNameError('character name empty')

if class_name not in VALID_CLASSES:
msg = f'invalid class name: "{class_name}"'
> raise InvalidClassNameError(msg)
E test_checks.InvalidClassNameError: invalid class name: "mage"

tests\test_checks.py:40: InvalidClassNameError
================ 1 failed, 1 passed in 0.05 seconds =================

test_empty_name passed as expected. test_invalid_class_name raised InvalidClassNameError, so the exception was not captured by pytest.raises, which failed the test (as any other exception would).