Chapter 6: Exception Testing and Edge Cases
Haiyue
18min
Chapter 6: Exception Testing and Edge Cases
Learning Objectives
- Master the use of pytest.raises
- Learn to verify exception messages
- Understand the importance of boundary value testing
- Master testing methods for error handling
Knowledge Points
Importance of Exception Testing
Exception testing is a critical component of software testing, ensuring that programs can:
- Raise correct exceptions: Fail when they should fail
- Provide meaningful error messages: Help users and developers understand issues
- Maintain system stability: Not crash due to exceptions
- Meet API contracts: Handle errors according to documentation specifications
pytest.raises Context Manager
pytest.raises is a specialized tool for testing exceptions:
with pytest.raises(ExceptionType):
# Code expected to raise exception
with pytest.raises(ExceptionType, match="error message pattern"):
# Verify exception type and error message
Boundary Value Testing Strategies
- Equivalence class partitioning: Divide inputs into valid and invalid equivalence classes
- Boundary value analysis: Test boundary values and values near boundaries
- Exception path testing: Test various exceptional conditions
Example Code
Basic Exception Testing
# test_exception_basic.py
import pytest
def divide(a, b):
"""Division function"""
if b == 0:
raise ValueError("Divisor cannot be zero")
if not isinstance(a, (int, float)) or not isinstance(b, (int, float)):
raise TypeError("Arguments must be numeric types")
return a / b
def test_divide_by_zero():
"""Test division by zero exception"""
with pytest.raises(ValueError):
divide(10, 0)
def test_divide_invalid_type():
"""Test type error exception"""
with pytest.raises(TypeError):
divide("10", 5)
with pytest.raises(TypeError):
divide(10, "5")
def test_divide_success():
"""Test normal division"""
assert divide(10, 2) == 5.0
assert divide(7, 3) == pytest.approx(2.333, rel=1e-2)
# Verify exception message
def test_divide_error_messages():
"""Test exception error messages"""
with pytest.raises(ValueError, match="Divisor cannot be zero"):
divide(10, 0)
with pytest.raises(TypeError, match="Arguments must be numeric types"):
divide("abc", 5)
Detailed Exception Message Verification
# test_exception_details.py
import pytest
import re
class CustomError(Exception):
"""Custom exception class"""
def __init__(self, message, code=None):
super().__init__(message)
self.code = code
def process_user_data(user_data):
"""Process user data"""
if not isinstance(user_data, dict):
raise TypeError("User data must be a dictionary type")
if "name" not in user_data:
raise CustomError("Missing required field: name", code="MISSING_NAME")
if len(user_data["name"]) < 2:
raise CustomError("Name length cannot be less than 2 characters", code="NAME_TOO_SHORT")
if "age" in user_data and user_data["age"] < 0:
raise ValueError("Age cannot be negative")
return {"status": "success", "user": user_data}
def test_exception_with_details():
"""Test exception details"""
# Test exception type and message
with pytest.raises(TypeError, match="User data must be a dictionary type"):
process_user_data("not a dict")
# Test custom exception and error code
with pytest.raises(CustomError) as exc_info:
process_user_data({})
assert exc_info.value.code == "MISSING_NAME"
assert "Missing required field: name" in str(exc_info.value)
# Test exception attributes
with pytest.raises(CustomError) as exc_info:
process_user_data({"name": "A"})
exception = exc_info.value
assert exception.code == "NAME_TOO_SHORT"
assert "Name length cannot be less than 2 characters" in str(exception)
def test_regex_match():
"""Use regex to match exception messages"""
with pytest.raises(ValueError, match=r"Age cannot be negative"):
process_user_data({"name": "Alice", "age": -5})
# More complex regex matching
with pytest.raises(CustomError, match=r"Missing required field: \w+"):
process_user_data({})
Boundary Value Testing
# test_boundary_values.py
import pytest
def validate_age(age):
"""Validate age"""
if not isinstance(age, int):
raise TypeError("Age must be an integer")
if age < 0:
raise ValueError("Age cannot be negative")
if age > 150:
raise ValueError("Age cannot exceed 150")
return True
def validate_score(score):
"""Validate score (0-100)"""
if not isinstance(score, (int, float)):
raise TypeError("Score must be a number")
if score < 0:
raise ValueError("Score cannot be below 0")
if score > 100:
raise ValueError("Score cannot exceed 100")
return True
class TestBoundaryValues:
"""Boundary value test class"""
@pytest.mark.parametrize("age", [0, 1, 149, 150])
def test_valid_age_boundaries(self, age):
"""Test valid age boundary values"""
assert validate_age(age) is True
@pytest.mark.parametrize("age", [-1, 151])
def test_invalid_age_boundaries(self, age):
"""Test invalid age boundary values"""
with pytest.raises(ValueError):
validate_age(age)
def test_age_type_validation(self):
"""Test age type validation"""
invalid_types = [1.5, "25", None, [], {}]
for invalid_age in invalid_types:
with pytest.raises(TypeError):
validate_age(invalid_age)
@pytest.mark.parametrize("score", [0, 0.1, 50, 99.9, 100])
def test_valid_score_boundaries(self, score):
"""Test valid score boundary values"""
assert validate_score(score) is True
@pytest.mark.parametrize("score", [-0.1, 100.1])
def test_invalid_score_boundaries(self, score):
"""Test invalid score boundary values"""
with pytest.raises(ValueError):
validate_score(score)
List and String Boundary Testing
# test_collection_boundaries.py
import pytest
def get_item(items, index):
"""Safely get list item"""
if not isinstance(items, list):
raise TypeError("items must be a list type")
if not isinstance(index, int):
raise TypeError("Index must be an integer")
if index < 0 or index >= len(items):
raise IndexError("Index out of range")
return items[index]
def substring(text, start, length):
"""Safely extract substring"""
if not isinstance(text, str):
raise TypeError("text must be a string")
if not isinstance(start, int) or not isinstance(length, int):
raise TypeError("start and length must be integers")
if start < 0:
raise ValueError("start cannot be negative")
if length < 0:
raise ValueError("length cannot be negative")
if start >= len(text):
raise IndexError("start exceeds string length")
return text[start:start + length]
class TestCollectionBoundaries:
"""Collection boundary tests"""
def test_list_valid_boundaries(self):
"""Test valid list boundaries"""
items = [1, 2, 3, 4, 5]
# Boundary values: first and last elements
assert get_item(items, 0) == 1
assert get_item(items, 4) == 5
def test_list_invalid_boundaries(self):
"""Test invalid list boundaries"""
items = [1, 2, 3]
# Negative index
with pytest.raises(IndexError):
get_item(items, -1)
# Out of range index
with pytest.raises(IndexError):
get_item(items, 3)
# Empty list boundary
empty_list = []
with pytest.raises(IndexError):
get_item(empty_list, 0)
def test_string_boundaries(self):
"""Test string boundaries"""
text = "Hello"
# Valid boundaries
assert substring(text, 0, 1) == "H"
assert substring(text, 4, 1) == "o"
assert substring(text, 0, 5) == "Hello"
# Boundary cases
assert substring(text, 0, 0) == ""
assert substring(text, 2, 10) == "llo" # Exceeding length auto-truncates
# Invalid boundaries
with pytest.raises(IndexError):
substring(text, 5, 1) # start equals length
with pytest.raises(ValueError):
substring(text, -1, 1) # negative start
with pytest.raises(ValueError):
substring(text, 0, -1) # negative length
Network and Filesystem Exception Testing
# test_external_exceptions.py
import pytest
import os
import tempfile
from unittest.mock import patch, mock_open
def read_config_file(file_path):
"""Read configuration file"""
if not os.path.exists(file_path):
raise FileNotFoundError(f"Configuration file does not exist: {file_path}")
if not os.access(file_path, os.R_OK):
raise PermissionError(f"No permission to read file: {file_path}")
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
if not content.strip():
raise ValueError("Configuration file cannot be empty")
return content
except UnicodeDecodeError:
raise ValueError("Configuration file encoding error")
def make_api_request(url, timeout=30):
"""Simulate API request"""
if not url.startswith(('http://', 'https://')):
raise ValueError("URL must start with http:// or https://")
if timeout <= 0:
raise ValueError("Timeout must be greater than 0")
# Simulate network exceptions
if "timeout" in url:
raise TimeoutError("Request timeout")
if "notfound" in url:
raise ConnectionError("Cannot connect to server")
return {"status": "success", "data": "mock response"}
class TestFileSystemExceptions:
"""Filesystem exception tests"""
def test_file_not_found(self):
"""Test file not found exception"""
with pytest.raises(FileNotFoundError, match="Configuration file does not exist"):
read_config_file("/nonexistent/file.txt")
def test_empty_file(self):
"""Test empty file exception"""
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
temp_path = f.name
try:
with pytest.raises(ValueError, match="Configuration file cannot be empty"):
read_config_file(temp_path)
finally:
os.unlink(temp_path)
@patch("builtins.open", mock_open(read_data="valid content"))
def test_valid_file_content(self):
"""Test valid file content"""
with patch("os.path.exists", return_value=True), \
patch("os.access", return_value=True):
result = read_config_file("mock_file.txt")
assert result == "valid content"
@patch("builtins.open", side_effect=UnicodeDecodeError("utf-8", b"", 0, 1, "invalid"))
def test_encoding_error(self):
"""Test encoding error"""
with patch("os.path.exists", return_value=True), \
patch("os.access", return_value=True):
with pytest.raises(ValueError, match="Configuration file encoding error"):
read_config_file("invalid_encoding.txt")
class TestNetworkExceptions:
"""Network exception tests"""
def test_invalid_url(self):
"""Test invalid URL"""
invalid_urls = ["ftp://example.com", "file:///path", "invalid-url"]
for url in invalid_urls:
with pytest.raises(ValueError, match="URL must start with http:// or https://"):
make_api_request(url)
def test_invalid_timeout(self):
"""Test invalid timeout"""
with pytest.raises(ValueError, match="Timeout must be greater than 0"):
make_api_request("https://api.example.com", timeout=0)
with pytest.raises(ValueError, match="Timeout must be greater than 0"):
make_api_request("https://api.example.com", timeout=-1)
def test_network_timeout(self):
"""Test network timeout"""
with pytest.raises(TimeoutError, match="Request timeout"):
make_api_request("https://timeout.example.com")
def test_connection_error(self):
"""Test connection error"""
with pytest.raises(ConnectionError, match="Cannot connect to server"):
make_api_request("https://notfound.example.com")
def test_successful_request(self):
"""Test successful request"""
result = make_api_request("https://api.example.com")
assert result["status"] == "success"
Context Manager Exception Testing
# test_context_manager_exceptions.py
import pytest
from contextlib import contextmanager
class DatabaseConnection:
"""Simulate database connection"""
def __init__(self, connection_string):
if not connection_string:
raise ValueError("Connection string cannot be empty")
self.connection_string = connection_string
self.connected = False
def connect(self):
"""Connect to database"""
if "invalid" in self.connection_string:
raise ConnectionError("Cannot connect to database")
self.connected = True
def disconnect(self):
"""Disconnect from database"""
self.connected = False
def __enter__(self):
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.disconnect()
# If there's an exception, don't suppress it
return False
@contextmanager
def temporary_setting(setting_name, value):
"""Temporary setting context manager"""
original_value = os.environ.get(setting_name)
try:
os.environ[setting_name] = value
yield
except Exception as e:
# Log exception but continue cleanup
print(f"Exception occurred: {e}")
raise
finally:
if original_value is None:
os.environ.pop(setting_name, None)
else:
os.environ[setting_name] = original_value
class TestContextManagerExceptions:
"""Context manager exception tests"""
def test_database_connection_success(self):
"""Test successful database connection"""
with DatabaseConnection("valid://connection"):
pass # Connection should be established and closed successfully
def test_database_connection_invalid_string(self):
"""Test invalid connection string"""
with pytest.raises(ValueError, match="Connection string cannot be empty"):
DatabaseConnection("")
def test_database_connection_failure(self):
"""Test database connection failure"""
with pytest.raises(ConnectionError, match="Cannot connect to database"):
with DatabaseConnection("invalid://connection"):
pass
def test_exception_in_context(self):
"""Test exception within context"""
with pytest.raises(RuntimeError, match="Error in context"):
with DatabaseConnection("valid://connection") as db:
assert db.connected is True
raise RuntimeError("Error in context")
def test_temporary_setting_context(self):
"""Test temporary setting context manager"""
import os
# Set test environment variable
test_var = "TEST_VARIABLE"
test_value = "test_value"
# Ensure test variable doesn't exist
os.environ.pop(test_var, None)
with temporary_setting(test_var, test_value):
assert os.environ[test_var] == test_value
# Variable should be cleaned up after context exits
assert test_var not in os.environ
def test_temporary_setting_with_exception(self):
"""Test temporary setting with exception"""
import os
test_var = "TEST_VARIABLE"
test_value = "test_value"
os.environ.pop(test_var, None)
with pytest.raises(ValueError, match="Test exception"):
with temporary_setting(test_var, test_value):
assert os.environ[test_var] == test_value
raise ValueError("Test exception")
# Variable should still be cleaned up even with exception
assert test_var not in os.environ
Exception Testing Best Practices
- Specific exception types: Test specific exception types, not generic Exception
- Verify error messages: Use match parameter to verify error message accuracy
- Boundary value coverage: Comprehensively test boundary values and out-of-boundary values
- Exception attributes: Test special attributes of custom exceptions
- Cleanup resources: Ensure proper resource cleanup in exceptional cases
Common Pitfalls
- Exceptions swallowed: Ensure tests actually raise expected exceptions
- Wrong exception type: Verify the correct type of exception is raised
- Side effects: Watch for side effects produced by exception tests
- Resource leaks: Resource management in exceptional cases
Exception testing and boundary case testing are important means to ensure software robustness. Comprehensive exception testing ensures software handles various exceptional situations correctly.