iT邦幫忙

2025 iThome 鐵人賽

DAY 8
0

今天要做什麼?

昨天我們學會了測試替身,解決了外部依賴的測試問題。今天面對一個新的挑戰:「如何測試程式在出錯時的行為?」

想像一個場景:你的應用需要處理各種錯誤情況:

  • API 回傳錯誤狀態碼
  • 使用者輸入無效資料
  • 檔案讀取失敗

很多開發者只測試「快樂路徑」(Happy Path),但真實世界充滿了意外。今天我們要學習如何徹底測試例外處理。

學習目標 🎯

今天結束後,你將學會:

  • 掌握 pytest 的 raises() 和例外斷言
  • 學會測試同步和異步錯誤
  • 理解錯誤測試的最佳實踐

為什麼要測試例外處理? 🤔

# 問題:只考慮成功情況的程式碼
class UserService:
    def get_user_profile(self, user_id: int):
        response = requests.get(f'/api/users/{user_id}')
        user = response.json()  # 如果不是 JSON 格式會怎樣?
        
        return {
            'id': user['id'],
            'name': user['name'].upper()  # 如果 name 是 None 會怎樣?
        }

例外處理測試確保:

  1. 系統穩定性:避免應用程式崩潰
  2. 使用者體驗:提供有意義的錯誤訊息
  3. 除錯效率:快速定位問題根源

pytest 中的例外測試 ⚙️

測試同步錯誤

建立 src/day08/validator.py

class ValidationError(Exception):
    def __init__(self, message: str, field: str = None):
        super().__init__(message)
        self.field = field

def validate_email(email):
    if not email:
        raise ValidationError('Email is required', 'email')
    
    if '@' not in email:
        raise ValidationError('Email must contain @ symbol', 'email')
    
    return True

def divide(a: float, b: float) -> float:
    if b == 0:
        raise ZeroDivisionError('Division by zero is not allowed')
    
    if not isinstance(a, (int, float)) or not isinstance(b, (int, float)):
        raise TypeError('Both arguments must be numbers')
    
    return a / b

建立 tests/day08/test_sync_exceptions.py

import pytest
from src.day08.validator import ValidationError, validate_email, divide

def test_email_validator_throws_error_when_email_is_missing():
    with pytest.raises(ValidationError, match='Email is required'):
        validate_email(None)
    
    with pytest.raises(ValidationError, match='Email is required'):
        validate_email('')

def test_email_validator_throws_error_when_format_is_invalid():
    with pytest.raises(ValidationError, match='Email must contain @ symbol'):
        validate_email('invalid-email')

def test_email_validator_throws_validation_error_with_correct_field():
    with pytest.raises(ValidationError) as exc_info:
        validate_email('invalid')
    
    error = exc_info.value
    assert error.field == 'email'
    assert '@ symbol' in str(error)

def test_email_validator_accepts_valid_emails():
    assert validate_email('user@example.com') is True

def test_divide_function_throws_error_for_division_by_zero():
    with pytest.raises(ZeroDivisionError, match='Division by zero is not allowed'):
        divide(10, 0)

def test_divide_function_throws_type_error_for_non_number_inputs():
    with pytest.raises(TypeError, match='Both arguments must be numbers'):
        divide('10', 2)

def test_divide_function_works_for_valid_inputs():
    assert divide(10, 2) == 5.0

測試異步函數的例外

建立 src/day08/async_service.py

class NetworkError(Exception):
    def __init__(self, message: str, status_code: int = None):
        super().__init__(message)
        self.status_code = status_code

class UserService:
    def __init__(self, http_client):
        self.http_client = http_client

    async def fetch_user(self, user_id: int):
        if not user_id or user_id <= 0:
            raise ValueError('Invalid user ID')

        try:
            response = await self.http_client.get(f'/users/{user_id}')
            
            if not response.ok:
                raise NetworkError(
                    f'Failed to fetch user: {response.status}', 
                    response.status
                )

            user = await response.json()
            
            if not user.get('id') or not user.get('name'):
                raise ValueError('Invalid user data received')

            return user
        except NetworkError:
            raise
        except Exception as error:
            raise NetworkError(f'Network request failed: {error}') from error

建立 tests/day08/test_async_exceptions.py

import pytest
from unittest.mock import AsyncMock, Mock
from src.day08.async_service import UserService, NetworkError

@pytest.fixture
def mock_http_client():
    return Mock(get=AsyncMock())

@pytest.fixture
def user_service(mock_http_client):
    return UserService(mock_http_client)

@pytest.mark.asyncio
async def test_fetch_user_throws_error_for_invalid_user_id(user_service):
    with pytest.raises(ValueError, match='Invalid user ID'):
        await user_service.fetch_user(0)
    
    with pytest.raises(ValueError, match='Invalid user ID'):
        await user_service.fetch_user(-1)

@pytest.mark.asyncio
async def test_fetch_user_throws_network_error_for_http_errors(user_service, mock_http_client):
    mock_response = Mock(ok=False, status=404)
    mock_http_client.get.return_value = mock_response

    with pytest.raises(NetworkError) as exc_info:
        await user_service.fetch_user(1)

    error = exc_info.value
    assert 'Failed to fetch user: 404' in str(error)
    assert error.status_code == 404

@pytest.mark.asyncio
async def test_fetch_user_returns_valid_user_data(user_service, mock_http_client):
    mock_user = {'id': 1, 'name': 'John Doe', 'email': 'john@example.com'}
    mock_response = Mock(ok=True)
    mock_response.json = AsyncMock(return_value=mock_user)
    mock_http_client.get.return_value = mock_response

    result = await user_service.fetch_user(1)
    assert result == mock_user

實戰範例:表單驗證測試 📝

建立 src/day08/form_validator.py

class FormValidationError(Exception):
    def __init__(self, errors: dict):
        super().__init__('Form validation failed')
        self.errors = errors

class UserForm:
    @staticmethod
    def validate(form_data: dict) -> bool:
        errors = {}
        
        if not form_data.get('email'):
            errors['email'] = 'Email is required'
        elif '@' not in form_data['email'] or '.' not in form_data['email']:
            errors['email'] = 'Invalid email format'
        
        if not form_data.get('password'):
            errors['password'] = 'Password is required'
        elif len(form_data['password']) < 8:
            errors['password'] = 'Password must be at least 8 characters'
        
        if form_data.get('password') != form_data.get('confirm_password'):
            errors['confirm_password'] = 'Passwords do not match'
        
        if errors:
            raise FormValidationError(errors)
        
        return True

建立 tests/day08/test_form_validator.py

import pytest
from src.day08.form_validator import UserForm, FormValidationError

def test_throws_form_validation_error_for_missing_fields():
    with pytest.raises(FormValidationError) as exc_info:
        UserForm.validate({})
    
    error = exc_info.value
    assert error.errors['email'] == 'Email is required'
    assert error.errors['password'] == 'Password is required'

def test_throws_error_for_invalid_email():
    form_data = {
        'email': 'invalid-email',
        'password': 'ValidPass123',
        'confirm_password': 'ValidPass123'
    }
    
    with pytest.raises(FormValidationError) as exc_info:
        UserForm.validate(form_data)
    
    assert 'email' in exc_info.value.errors
    assert 'Invalid email format' in exc_info.value.errors['email']

def test_accepts_valid_form_data():
    valid_form_data = {
        'email': 'user@example.com',
        'password': 'ValidPass123',
        'confirm_password': 'ValidPass123'
    }
    
    assert UserForm.validate(valid_form_data) is True

最佳實踐 ✨

# ✅ 具體的錯誤驗證
def test_throws_validation_error_with_field_information():
    with pytest.raises(ValidationError) as exc_info:
        validate_email('invalid')
    
    error = exc_info.value
    assert error.field == 'email'
    assert '@ symbol' in str(error)

# ✅ 全面測試錯誤情況
def test_handles_all_invalid_inputs():
    cases = [
        {'input': None, 'error': 'Email is required'},
        {'input': 'invalid', 'error': 'Email must contain @ symbol'}
    ]
    
    for case in cases:
        with pytest.raises(ValidationError, match=case['error']):
            validate_email(case['input'])

完整實作 🛒

建立 tests/day08/test_complete_example.py

import pytest
from unittest.mock import Mock

class ECommerceError(Exception):
    def __init__(self, message: str, code: str = None):
        super().__init__(message)
        self.code = code

class ShoppingCart:
    def __init__(self, inventory):
        self.inventory = inventory
        self.items = []

    def add_item(self, product_id, quantity: int = 1):
        if not product_id:
            raise ECommerceError('Product ID is required', 'INVALID_PRODUCT_ID')

        if not isinstance(quantity, int) or quantity <= 0:
            raise ECommerceError('Quantity must be positive', 'INVALID_QUANTITY')

        product = self.inventory.get_product(product_id)
        if not product:
            raise ECommerceError('Product not found', 'PRODUCT_NOT_FOUND')

        if not self.inventory.is_available(product_id, quantity):
            raise ECommerceError('Insufficient stock', 'INSUFFICIENT_STOCK')

        self.items.append({'product_id': product_id, 'quantity': quantity})

def test_shopping_cart_exception_handling():
    mock_inventory = Mock()
    cart = ShoppingCart(mock_inventory)

    # 測試無效商品 ID
    with pytest.raises(ECommerceError) as exc_info:
        cart.add_item(None)
    
    assert exc_info.value.code == 'INVALID_PRODUCT_ID'

    # 測試無效數量
    with pytest.raises(ECommerceError) as exc_info:
        cart.add_item('valid-id', 0)
    
    assert exc_info.value.code == 'INVALID_QUANTITY'

    # 測試商品不存在
    mock_inventory.get_product.return_value = None
    
    with pytest.raises(ECommerceError) as exc_info:
        cart.add_item('nonexistent')
    
    assert exc_info.value.code == 'PRODUCT_NOT_FOUND'

    # 測試庫存不足
    mock_inventory.get_product.return_value = Mock()
    mock_inventory.is_available.return_value = False
    
    with pytest.raises(ECommerceError) as exc_info:
        cart.add_item('product-1', 10)
    
    assert exc_info.value.code == 'INSUFFICIENT_STOCK'

    # 測試成功添加
    mock_inventory.is_available.return_value = True
    cart.add_item('product-1', 2)  # Should not raise
    
    assert len(cart.items) == 1
    assert cart.items[0]['product_id'] == 'product-1'

今日學習地圖 🗺️

我們現在在測試基礎概念的倒數第三天,已經掌握了大部分核心測試技術:

基礎概念 (1-10)
├── Day 1: 環境設定 ✅
├── Day 2: 基本斷言 ✅
├── Day 3: TDD 循環 ✅
├── Day 4: 測試結構 ✅
├── Day 5: 生命週期 ✅
├── Day 6: 參數化測試 ✅
├── Day 7: 測試替身 ✅
├── Day 8: 例外處理測試 📍 今天
├── Day 9: 測試覆蓋率
└── Day 10: 重構技巧

今天學到什麼?

透過今天的學習,我們掌握了:

  1. pytest 的錯誤斷言raises()match 參數等方法
  2. 同步和異步錯誤測試:不同類型錯誤的測試技巧
  3. 自定義例外類別:創建有意義的錯誤類型和資訊
  4. 最佳實踐:具體的錯誤驗證和全面的測試覆蓋

例外處理測試讓我們能夠確保程式優雅地處理錯誤,提供有用的錯誤訊息,並維持系統的穩定性。

總結

今天我們學會了例外處理測試,確保程式在各種錯誤情況下都能正確回應。明天我們將學習「測試覆蓋率」,了解如何衡量測試的完整性。

記住:好的例外處理測試不只是檢查是否拋出錯誤,更要驗證錯誤類型、錯誤訊息,以及錯誤發生後的系統狀態!


上一篇
Day 07 - 測試替身基礎 🎭
系列文
Python pytest TDD 實戰:從零開始的測試驅動開發8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言