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]
Press Shift+Tab to enter Plan Mode before predicting.
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
Press Shift+Tab to exit Plan Mode.
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.
If you want to go deeper, run /investigate @test_exceptions_practice.py in Claude Code and ask about the difference between catching the exception type and matching the message.
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.
Prompt 3: Edge Cases the AI Might Miss
I have a function categorize_by_count(word_count: int) -> str that
returns "short", "medium", or "long" based on thresholds.
List 5 edge case inputs that a typical AI-generated test suite
would miss. For each one, explain what could go wrong and write
the pytest test that would catch it.
What you're learning: You are using the AI to generate a checklist of blind spots, then evaluating whether the suggestions match the edge case taxonomy from this lesson.
James adds pytest.raises tests for three edge cases. His test suite now checks both what should work and what should fail. "It is like testing a conveyor belt system. You check that good packages move through, and you also check that the reject gate catches defective ones. If the gate never fires, you do not know whether it works."
"Exactly," Emma says. "I learned that the hard way. I shipped a validation function that was supposed to reject empty strings, but I never tested it with an empty string. The function had a bug in the rejection logic, and it silently accepted everything." She shakes her head. "The code looked right. The test for the happy path passed. But the edge case test I never wrote would have caught it."
"So now we test values that work, values that should crash, and the boundary between them," James says. "How do I know whether I have tested enough?"
"That is the question coverage answers. Lesson 5: you run a tool that tracks which lines of code actually executed during your tests, and it shows you exactly which lines no test has ever touched."