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.
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.
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:
pytest.raises(ValueError)tells pytest to expect aValueError.- The indented line
int("hello")is the code being tested. int("hello")raises aValueErrorbecause"hello"is not a number.- 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 Case | Example | Why it matters |
|---|---|---|
None | process(None) | Functions may crash on None if they expect a string or number |
| Empty string | process("") | String operations behave differently on empty strings |
| Zero | divide(10, 0) | Division by zero, zero-length collections |
| Negative numbers | categorize(-5) | Functions with thresholds may not handle negatives |
| Boundary values | categorize(200) if threshold is > 200 | Off-by-one: does 200 count as "medium" or "short"? |
| Very large values | categorize(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:
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:
test_int_rejects_empty_string: Verifyint("")raisesValueError.test_float_rejects_text: Verifyfloat("abc")raisesValueErrorwithmatch="could not convert".test_list_index_out_of_range: Create a listitems: list[str] = ["a", "b"]and verifyitems[5]raisesIndexError.
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:
- Test that
int("not_a_number")raisesValueError - Test that
int("not_a_number")error message contains"invalid literal"(usematch) - Test that accessing a missing dictionary key raises
KeyError - Test that
float("")raisesValueError - Test that an empty list
[]access at index 0 raisesIndexError
All five must pass. Run uv run pytest test_exceptions_mastery.py -v and confirm.
Try With AI
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
-
pytest.raisestests that code raises the correct exception. If the code raises the expected error, the test passes. If no error is raised, the test fails. -
with pytest.raises(ErrorType):is a single pattern. Thewithkeyword creates a context that catches the exception. The full mechanism behindwithis covered in Chapter 54. -
match=checks the error message using plain substring matching.match="invalid literal"passes if the error message contains that text anywhere. -
Edge cases are inputs at the boundaries of what your code handles. Test None, empty strings, zero, negative numbers, and boundary values systematically.
-
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?"