Skip to main content

Testing Exceptions and Edge Cases

James writes a test for int("hello") and expects it to fail. It does fail, but pytest marks his test as FAILED, not PASSED. "I wanted it to fail," he says. "How do I tell pytest that a crash is the correct behavior?"

Emma types two words above the line: with pytest.raises. "This tells pytest: the code inside this block should raise an error. If it does, the test passes. If it does not, the test fails."

Until now, every test has checked that code produces the right value. But sometimes the right behavior is to reject bad input. Passing "hello" to int() should raise a ValueError. Accessing a missing dictionary key should raise a KeyError. Testing that these errors happen is just as important as testing that correct inputs produce correct outputs.

If you're new to programming

An exception is Python's way of signaling that something went wrong. Instead of returning a value, the function stops and raises an error. You have seen error messages in your terminal when code crashes. This lesson teaches you how to write tests that expect and verify those crashes.

If you have testing experience from another language

pytest.raises works like Assert.Throws<T> in NUnit or assertThrows in JUnit. Python uses a context manager (with) instead of a lambda or callback. The match parameter checks the error message as a plain substring.


The with Keyword: A Brief Scaffold

Before using pytest.raises, you need one new keyword: with. The with keyword creates a temporary context. It sets something up, runs the indented block, and cleans up afterward. The full mechanism behind with is covered in Chapter 54. For now, treat this as a single phrase:

with pytest.raises(ValueError):
# code that should raise ValueError goes here

Read it as: "Within the context of expecting a ValueError, run this code." If the code raises a ValueError, the with block catches it and the test passes. If the code does not raise a ValueError, pytest fails the test.

You saw with briefly in Chapter 43, Lesson 1, during a reading exercise. You do not need to understand the full mechanism yet. For this lesson, with pytest.raises(SomeError): is the pattern. Chapter 54 explains what with does under the hood.


Your First Exception Test

Python's built-in int() function raises a ValueError when it cannot convert a string to a number:

import pytest


def test_int_rejects_non_numeric_string() -> None:
with pytest.raises(ValueError):
int("hello")

Breaking this down:

  1. pytest.raises(ValueError) tells pytest to expect a ValueError.
  2. The indented line int("hello") is the code being tested.
  3. int("hello") raises a ValueError because "hello" is not a number.
  4. pytest catches the error and marks the test as PASSED.

If you replaced "hello" with "42", the int() call would succeed (no error raised), and pytest would FAIL the test because the expected ValueError never happened.


Testing Dictionary KeyError

Another common built-in exception is KeyError, raised when you access a dictionary key that does not exist:

def test_missing_key_raises_key_error() -> None:
config: dict[str, str] = {"theme": "dark", "language": "en"}

with pytest.raises(KeyError):
value: str = config["missing_key"]

The dictionary has keys "theme" and "language", but no key "missing_key". Python raises a KeyError. The with pytest.raises(KeyError) block catches it, and the test passes.


Checking Error Messages with match

Sometimes you want to verify not just the exception type but also the error message. The match parameter checks whether the error message contains a specific piece of text:

def test_int_error_message_mentions_invalid_literal() -> None:
with pytest.raises(ValueError, match="invalid literal"):
int("hello")

When int("hello") raises a ValueError, Python's error message is: invalid literal for int() with base 10: 'hello'. The match="invalid literal" check passes because the error message contains the text "invalid literal".

The match parameter checks if the error message contains your text. You do not need any special syntax; just provide the text you expect to find in the error message. (Technically, match uses pattern matching under the hood, but plain text works perfectly. Treat it as a substring check for now.)

def test_key_error_shows_key_name() -> None:
settings: dict[str, int] = {"font_size": 12}

with pytest.raises(KeyError, match="color"):
value: int = settings["color"]

Python's KeyError message includes the key name. The match="color" check confirms the error is about the "color" key specifically, not some other missing key.


The Edge Case Taxonomy

Testing exceptions is part of a broader practice: testing edge cases. Edge cases are inputs that sit at the boundaries of what your code handles. A function that works for typical inputs might break for unusual ones.

Here is a taxonomy of common edge cases to check:

Edge CaseExampleWhy it matters
Noneprocess(None)Functions may crash on None if they expect a string or number
Empty stringprocess("")String operations behave differently on empty strings
Zerodivide(10, 0)Division by zero, zero-length collections
Negative numberscategorize(-5)Functions with thresholds may not handle negatives
Boundary valuescategorize(200) if threshold is > 200Off-by-one: does 200 count as "medium" or "short"?
Very large valuescategorize(999999)Integer overflow (rare in Python) or performance issues

Not every edge case raises an exception. Some produce unexpected return values. The point is to test them systematically rather than only testing the "happy path" of normal inputs.


Edge Case Testing in Practice

Here is a function and a set of edge case tests:

Reading raise before writing it

This function uses raise ValueError(...) to signal an error. You are reading this pattern in AI-generated code, not writing it yourself yet. Chapter 54 teaches you how to raise your own exceptions. For now, focus on how to test that the exception occurs.

def safe_divide(numerator: float, denominator: float) -> float:
"""Divide two numbers. Raises ValueError if denominator is zero."""
if denominator == 0:
raise ValueError("Cannot divide by zero")
return numerator / denominator

Tests for the happy path and the edge cases:

def test_safe_divide_normal_inputs() -> None:
result: float = safe_divide(10.0, 2.0)
assert result == 5.0


def test_safe_divide_zero_denominator_raises_error() -> None:
with pytest.raises(ValueError, match="Cannot divide by zero"):
safe_divide(10.0, 0)


def test_safe_divide_zero_numerator_returns_zero() -> None:
result: float = safe_divide(0, 5.0)
assert result == 0.0


def test_safe_divide_negative_numbers() -> None:
result: float = safe_divide(-10.0, 2.0)
assert result == -5.0

Four tests, four different scenarios. The second test checks that the function raises the correct exception. The others check that unusual but valid inputs produce the right results.


Combining pytest.raises with Fixtures

You can use fixtures from Lesson 2 alongside pytest.raises:

import pytest
from smartnotes.models import Note


@pytest.fixture
def empty_config() -> dict[str, str]:
"""A configuration dictionary with no entries."""
return {}


def test_empty_config_missing_key(empty_config: dict[str, str]) -> None:
with pytest.raises(KeyError, match="theme"):
value: str = empty_config["theme"]

The fixture provides the test data (Arrange), and pytest.raises verifies the expected error (Assert). The Act phase is the dictionary access inside the with block.


PRIMM-AI+ Practice: Exception Testing

Predict [AI-FREE]

Look at each test below without running it. Predict whether it will PASS or FAIL. Write your predictions and a confidence score from 1 to 5 before checking.

# Test A
def test_a() -> None:
with pytest.raises(ValueError):
int("42")

# Test B
def test_b() -> None:
with pytest.raises(KeyError):
data: dict[str, int] = {"age": 25}
value: int = data["name"]

# Test C
def test_c() -> None:
with pytest.raises(ValueError, match="base 10"):
int("hello")

# Test D
def test_d() -> None:
with pytest.raises(TypeError):
int("hello")
Check your predictions

Test A: FAILS. int("42") succeeds (returns 42). No ValueError is raised, so pytest.raises fails the test because it expected an exception that never happened.

Test B: PASSES. data["name"] raises a KeyError because "name" is not in the dictionary. The pytest.raises(KeyError) block catches it.

Test C: PASSES. int("hello") raises ValueError with message "invalid literal for int() with base 10: 'hello'". The message contains "base 10", so the match check passes.

Test D: FAILS. int("hello") raises a ValueError, not a TypeError. The test expects TypeError, so pytest reports a failure: wrong exception type.

Run

Create a file called test_exceptions_practice.py. Write these three tests:

  1. test_int_rejects_empty_string: Verify int("") raises ValueError.
  2. test_float_rejects_text: Verify float("abc") raises ValueError with match="could not convert".
  3. test_list_index_out_of_range: Create a list items: list[str] = ["a", "b"] and verify items[5] raises IndexError.

Run uv run pytest test_exceptions_practice.py -v and confirm all three pass.

Investigate

Change the match string in test 2 to something that does not appear in the error message (like match="xyz"). Run the test. Read the failure output. pytest shows you the actual error message so you can see what the text should be.

Modify

Add a fourth test that combines edge case testing with the taxonomy table. Pick a Python built-in function (like len, max, or sorted) and test what happens when you pass it an unexpected input type. Does it raise TypeError? Test it.

Make [Mastery Gate]

Without looking at any examples, write a test file called test_exceptions_mastery.py with five tests:

  1. Test that int("not_a_number") raises ValueError
  2. Test that int("not_a_number") error message contains "invalid literal" (use match)
  3. Test that accessing a missing dictionary key raises KeyError
  4. Test that float("") raises ValueError
  5. Test that an empty list [] access at index 0 raises IndexError

All five must pass. Run uv run pytest test_exceptions_mastery.py -v and confirm.


Try With AI

Opening Claude Code

If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.

Prompt 1: Generate Edge Case Tests

I have a function called categorize_by_count(word_count: int) -> str
that returns "short" for counts under 200, "medium" for 200-1000,
and "long" for above 1000.

Write edge case tests for this function. Include tests for:
zero, negative numbers, and the exact boundary values (200 and 1000).
Use type annotations on all variables.

Review the AI's output. Check: does it test zero? Negative values? Both sides of each boundary (199 vs. 200, 1000 vs. 1001)? If it missed any, tell it which edge cases to add.

What you're learning: You are using the edge case taxonomy to evaluate whether AI-generated tests are thorough.

Prompt 2: Explain an Exception Test

What does this test do, step by step?

def test_config_missing_key() -> None:
config: dict[str, str] = {"host": "localhost"}
with pytest.raises(KeyError, match="port"):
value: str = config["port"]

Why does this test pass? What would make it fail?

Read the AI's explanation. It should describe the with pytest.raises pattern, explain that accessing config["port"] raises a KeyError, and explain that the match="port" check verifies the error message. Compare its explanation to what you learned in this lesson.

What you're learning: You are verifying that the AI's explanation matches your understanding of exception testing mechanics.


Key Takeaways

  1. pytest.raises tests that code raises the correct exception. If the code raises the expected error, the test passes. If no error is raised, the test fails.

  2. with pytest.raises(ErrorType): is a single pattern. The with keyword creates a context that catches the exception. The full mechanism behind with is covered in Chapter 54.

  3. match= checks the error message using plain substring matching. match="invalid literal" passes if the error message contains that text anywhere.

  4. Edge cases are inputs at the boundaries of what your code handles. Test None, empty strings, zero, negative numbers, and boundary values systematically.

  5. Testing exceptions is testing specifications. If invalid input should produce an error, a test proving it does is part of your specification.


Looking Ahead

You can now test both correct outputs and correct errors. In Lesson 5, you will learn to measure how much of your code is actually tested using coverage reports. Coverage answers the question: "Which lines of code has no test ever executed?"