昨天我們學會了測試替身,解決了外部依賴的測試問題。今天面對一個新的挑戰:「如何測試程式在出錯時的行為?」
想像一個場景:你的應用需要處理各種錯誤情況:
很多開發者只測試「快樂路徑」(Happy Path),但真實世界充滿了意外。今天我們要學習如何徹底測試例外處理。
今天結束後,你將學會:
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 會怎樣?
}
例外處理測試確保:
建立 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: 重構技巧
透過今天的學習,我們掌握了:
raises()
、match
參數等方法例外處理測試讓我們能夠確保程式優雅地處理錯誤,提供有用的錯誤訊息,並維持系統的穩定性。
今天我們學會了例外處理測試,確保程式在各種錯誤情況下都能正確回應。明天我們將學習「測試覆蓋率」,了解如何衡量測試的完整性。
記住:好的例外處理測試不只是檢查是否拋出錯誤,更要驗證錯誤類型、錯誤訊息,以及錯誤發生後的系統狀態!