Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Asserting and Verifying Expected Exceptions

Hello! Last time, you kept an incident-notice test independent of an external on-call service by supplying a test double. That double returned a known owner. A collaborator can also fail, and sometimes failure is the behavior your code is supposed to report.

In this lesson, you’ll use pytest.raises() to verify which exception occurs and inspect details that matter to a caller: an incident ID, a useful message, or the original failure. This is part of building tests that check professional Python code’s failure behavior as carefully as its successful results.


Make failure an explicit expectation

Ordinarily, an exception escaping a test causes the test to fail. If the exception is the intended result, put only the operation expected to raise inside pytest.raises(). Pytest then fails the test if that operation raises nothing or raises the wrong type.

Watch pytest: testing exceptions (beginner - intermediate) anthony explains #176 by anthonywritescode. The example uses version parsing rather than incidents, but the testing pattern is the same.

pytest: testing exceptions (beginner - intermediate) anthony explains #176

Watch how pytest.raises() replaces a manual try/except, how the captured exception becomes available after the block, and why the expected type should be specific.

Start with expecting an error. Then watch capturing details; notice that inspection happens after the with block. Finish with choosing a type, particularly the warning about expecting Exception so broadly that an unrelated bug could pass the test.

For a small incident-domain example, suppose IDs must be positive. Add src/incident_app/incident_ids.py:

class InvalidIncidentId(ValueError):
    def __init__(self, incident_id: int) -> None:
        self.incident_id = incident_id
        super().__init__(f"Incident ID must be positive: {incident_id}")


def require_positive_incident_id(incident_id: int) -> int:
    if incident_id <= 0:
        raise InvalidIncidentId(incident_id)
    return incident_id

The custom exception gives callers a recognizable error type and a structured incident_id attribute. The message is useful to a person reading a failure, but a caller does not have to parse that message to recover the ID.

Create tests/test_incident_ids.py:

import pytest

from incident_app.incident_ids import (
    InvalidIncidentId,
    require_positive_incident_id,
)


def test_rejects_negative_incident_id() -> None:
    with pytest.raises(InvalidIncidentId):
        require_positive_incident_id(-7)

The test passes only when the call raises InvalidIncidentId or one of its subclasses. If the function starts accepting -7, pytest reports that the expected exception was not raised. If it raises an unrelated exception instead, the test fails rather than treating every failure as success.


Verify the detail that explains the failure

An exception type alone may not distinguish the right failure from a different failure of the same type. Pytest’s guide shows two ways to inspect it: capture the exception with as excinfo, or use match when the message itself matters.

How to write and report assertions in tests

Read pytest’s “Assertions about expected exceptions” and “Matching exception messages” sections to see what raises() captures and what its message check actually means.

Under “Assertions about expected exceptions,” begin with the paragraph starting “In order to write assertions about raised exceptions” and read through the type discussion, including the intervening examples. Notice that an expected base class also accepts a subclass. Under “Matching exception messages,” start at the paragraph introducing a regular-expression check and read through the first note. Focus on the fact that match searches the message; it does not automatically compare the whole string.

Update the negative-ID test to check the detail the caller could use:

def test_rejects_negative_incident_id() -> None:
    with pytest.raises(InvalidIncidentId) as excinfo:
        require_positive_incident_id(-7)

    assert excinfo.value.incident_id == -7

excinfo.value is the exception instance. Inspect it after the with block: raising the exception ends execution inside that block, so an assertion placed after the raising call but still inside the block would never run.

For a user-visible diagnostic whose wording is part of the intended behavior, match is concise:

def test_zero_id_has_useful_error_message() -> None:
    with pytest.raises(
        InvalidIncidentId,
        match=r"^Incident ID must be positive: 0$",
    ):
        require_positive_incident_id(0)

match takes a regular expression, not a plain-text equality check. The anchors ^ and $ make this example check the whole message; without them, a matching fragment anywhere in the message would suffice. Prefer checking structured attributes when they express the contract more directly. Check exact wording when that wording genuinely matters, rather than making tests fragile over incidental punctuation.

Pytest accepts subclasses of the expected exception type. If your contract genuinely requires the exact class, you can capture it and add assert excinfo.type is InvalidIncidentId. Most tests do not need that extra restriction.


Check failures across a substituted boundary

The test double from the previous lesson can model failure as well as success. format_incident_notice() calls an injected owner lookup; it does not handle lookup failures. If your intended contract is to let that failure reach the caller, you can test it without contacting the real service. Add this test to tests/test_notice.py:

from incident_app.notice import format_incident_notice


def test_notice_propagates_owner_lookup_failure() -> None:
    outage = ConnectionError("on-call lookup unavailable")

    def unavailable_lookup(incident_id: int) -> str:
        raise outage

    with pytest.raises(ConnectionError) as excinfo:
        format_incident_notice(42, "Investigating", unavailable_lookup)

    assert excinfo.value is outage

This checks more than “something went wrong”: the caller receives the same failure produced by the collaborator. The lookup is still a focused double; no network request occurs. As in the ID tests, keep the with block narrow. If you put setup or several calls inside it, an exception from the wrong operation could satisfy the expectation.

Run the tests with:

python -m pytest -v tests/test_incident_ids.py tests/test_notice.py

Avoid pytest.raises(Exception) for these cases. A TypeError caused by a programming mistake could then make a test appear to pass even though the intended incident or lookup failure never occurred. Choose the narrowest exception type promised by the behavior you are testing.


Takeaways

Use pytest.raises(SpecificError) around the call expected to fail. Capture excinfo to inspect stable attributes or the original exception; use match when message text is an important part of the contract, remembering that it performs a regular-expression search. Precise expectations make failure tests useful rather than merely tolerant of errors.

Next, you’ll measure branch coverage and examine whether your tests exercise meaningful behavior on both successful and failing paths.

Can't find a good explanation? Sign up and we'll make it for you

Sign up