Errors and Exceptions
In this chapter, we'll learn how to handle errors gracefully in Python using TDD. We'll cover:
- Understanding Python exceptions
try,except,finally, andelseblocks- Raising exceptions with
raise - Creating custom exception classes
- Testing that exceptions are raised correctly
Understanding exceptions
When something goes wrong in Python, it raises an exception:
# ZeroDivisionError
1 / 0
# IndexError
[1, 2, 3][10]
# KeyError
{"a": 1}["b"]
# TypeError
"hello" + 5
# ValueError
int("not a number")
Unhandled exceptions crash your program. We use exception handling to deal with errors gracefully.
The try/except block
Write the test first
Let's write a function that safely divides two numbers:
from calculator import safe_divide
def test_safe_divide():
got = safe_divide(10, 2)
want = 5.0
assert got == want
def test_safe_divide_by_zero():
got = safe_divide(10, 0)
want = None
assert got is None
Make it pass
def safe_divide(a, b):
"""Divide a by b, returning None if b is zero."""
try:
return a / b
except ZeroDivisionError:
return None
The try block contains code that might raise an exception. The except block handles it.
Catching specific exceptions
Always catch specific exceptions, not bare except:
# Good - catches specific exception
try:
result = int(user_input)
except ValueError:
print("Please enter a valid number")
# Bad - catches everything, including KeyboardInterrupt
try:
result = int(user_input)
except: # Don't do this!
print("Something went wrong")
Catching multiple exceptions
def parse_value(value):
"""Parse a value that might be a string number or list index."""
try:
return int(value)
except (ValueError, TypeError) as e:
return None
Or handle them differently:
def get_item(data, key):
try:
return data[key]
except KeyError:
return f"Key {key} not found"
except IndexError:
return f"Index {key} out of range"
except TypeError:
return f"Cannot index with {type(key)}"
The else clause
The else block runs if no exception was raised:
def test_parse_with_else():
got = parse_number("42")
want = (42, True)
assert got == want
def test_parse_invalid():
got = parse_number("not a number")
want = (None, False)
assert got == want
Implementation:
def parse_number(text):
"""Parse a number, returning (value, success) tuple."""
try:
value = int(text)
except ValueError:
return None, False
else:
return value, True
The finally clause
The finally block always runs, regardless of exceptions:
def read_file(filename):
"""Read a file, ensuring it's always closed."""
file = None
try:
file = open(filename, 'r')
return file.read()
except FileNotFoundError:
return None
finally:
if file:
file.close() # Always runs
Note: In practice, use context managers (with statement) for file handling.
Raising exceptions
Use raise to signal errors in your code:
Write the test first
import pytest
from calculator import divide
def test_divide():
got = divide(10, 2)
want = 5.0
assert got == want
def test_divide_by_zero_raises():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)
Make it pass
def divide(a, b):
"""Divide a by b.
Raises:
ValueError: If b is zero.
"""
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
Testing exceptions with pytest
pytest provides several ways to test exceptions:
Basic exception testing
import pytest
def test_raises_value_error():
with pytest.raises(ValueError):
int("not a number")
Checking the exception message
def test_raises_with_message():
with pytest.raises(ValueError, match="invalid literal"):
int("not a number")
Inspecting the exception
def test_exception_details():
with pytest.raises(ValueError) as exc_info:
int("not a number")
assert "invalid literal" in str(exc_info.value)
Custom exceptions
Create custom exceptions for your domain:
class BankError(Exception):
"""Base exception for bank operations."""
pass
class InsufficientFundsError(BankError):
"""Raised when withdrawal exceeds balance."""
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(
f"Cannot withdraw ${amount}: only ${balance} available"
)
class AccountLockedError(BankError):
"""Raised when account is locked."""
pass
Testing custom exceptions
import pytest
from bank import BankAccount, InsufficientFundsError
def test_withdraw_insufficient_funds():
account = BankAccount("Alice", 100)
with pytest.raises(InsufficientFundsError) as exc_info:
account.withdraw(150)
assert exc_info.value.balance == 100
assert exc_info.value.amount == 150
Implementation:
class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise InsufficientFundsError(self.balance, amount)
self.balance -= amount
Exception chaining
When raising a new exception from another, use from:
def load_config(filename):
"""Load configuration from a file."""
try:
with open(filename) as f:
return parse_config(f.read())
except FileNotFoundError as e:
raise ConfigurationError(f"Config file not found: {filename}") from e
except ValueError as e:
raise ConfigurationError(f"Invalid config format") from e
The original exception is preserved in __cause__.
Re-raising exceptions
Sometimes you want to log an exception and re-raise it:
def process_data(data):
try:
return transform(data)
except ValueError:
print(f"Failed to process: {data}")
raise # Re-raises the current exception
Best practices
1. Be specific
# Good
try:
value = int(text)
except ValueError:
handle_invalid_number()
# Bad
try:
value = int(text)
except Exception: # Too broad
handle_error()
2. Don't silence exceptions
# Bad - silently ignores errors
try:
do_something()
except Exception:
pass
# Good - at least log it
try:
do_something()
except Exception as e:
logger.error(f"Failed: {e}")
3. Use exceptions for exceptional cases
# Bad - using exceptions for control flow
def find_user(users, name):
try:
return next(u for u in users if u.name == name)
except StopIteration:
return None
# Good - normal control flow
def find_user(users, name):
for user in users:
if user.name == name:
return user
return None
4. Document exceptions
def parse_json(text):
"""Parse JSON text.
Args:
text: The JSON string to parse.
Returns:
The parsed Python object.
Raises:
ValueError: If the text is not valid JSON.
"""
import json
try:
return json.loads(text)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON: {e}") from e
A complete example: Validation
Let's build a user validator with proper exception handling:
# test_validator.py
import pytest
from validator import validate_user, ValidationError
def test_valid_user():
user = {"name": "Alice", "email": "alice@example.com", "age": 25}
# Should not raise
validate_user(user)
def test_missing_name():
user = {"email": "alice@example.com", "age": 25}
with pytest.raises(ValidationError, match="name is required"):
validate_user(user)
def test_invalid_email():
user = {"name": "Alice", "email": "not-an-email", "age": 25}
with pytest.raises(ValidationError, match="Invalid email"):
validate_user(user)
def test_invalid_age():
user = {"name": "Alice", "email": "alice@example.com", "age": -5}
with pytest.raises(ValidationError, match="Age must be positive"):
validate_user(user)
Implementation:
# validator.py
import re
class ValidationError(Exception):
"""Raised when validation fails."""
pass
def validate_user(user):
"""Validate a user dictionary.
Raises:
ValidationError: If validation fails.
"""
if "name" not in user:
raise ValidationError("name is required")
if "email" not in user:
raise ValidationError("email is required")
if not re.match(r"[^@]+@[^@]+\.[^@]+", user["email"]):
raise ValidationError("Invalid email format")
if user.get("age", 0) < 0:
raise ValidationError("Age must be positive")
Wrapping up
We've covered:
- try/except - Handle exceptions gracefully
- except with type - Catch specific exception types
- else clause - Run code only if no exception occurred
- finally clause - Always run cleanup code
- raise - Signal errors with exceptions
- Custom exceptions - Create domain-specific error types
- pytest.raises - Test that exceptions are raised
- Exception chaining - Preserve original exception with
from
TDD and exceptions
When testing exception handling:
- Test the happy path (no exceptions)
- Test that exceptions are raised for error conditions
- Verify exception messages are helpful
- Test exception attributes for custom exceptions
Good exception handling makes code more robust and easier to debug. Always be specific about what you catch, and always test your error paths!