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
  1. Specific exception types: Test specific exception types, not generic Exception
  2. Verify error messages: Use match parameter to verify error message accuracy
  3. Boundary value coverage: Comprehensively test boundary values and out-of-boundary values
  4. Exception attributes: Test special attributes of custom exceptions
  5. Cleanup resources: Ensure proper resource cleanup in exceptional cases
Common Pitfalls
  1. Exceptions swallowed: Ensure tests actually raise expected exceptions
  2. Wrong exception type: Verify the correct type of exception is raised
  3. Side effects: Watch for side effects produced by exception tests
  4. 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.