跳轉到

單元測試

單元測試的基本概念

單元測試是測試金字塔的基礎層,專注於測試代碼的最小單元(通常是函數或方法)。在 FastAPI 應用程序中,單元測試主要針對不依賴於 HTTP 請求/響應流程的獨立組件。

概念 說明
測試範圍 單一函數、方法或類的行為
隔離性 與其他組件和外部系統完全隔離
執行速度 非常快速,通常毫秒級
依賴處理 使用模擬(mock)或存根(stub)替代外部依賴
數量比例 在測試金字塔中佔比最大,約 70-80%

FastAPI 中的單元測試目標

在 FastAPI 應用中,以下組件是單元測試的主要目標:

組件類型 測試重點 示例
工具函數 輸入/輸出轉換、格式化、計算 日期轉換、金額計算、字符串處理
業務邏輯 核心業務規則和算法 折扣計算、資格驗證、狀態轉換
Pydantic 模型 數據驗證和轉換 模型實例化、驗證、轉換方法
依賴函數 提供給路由的可注入依賴 權限檢查、參數驗證
自定義異常 異常行為和屬性 錯誤碼、錯誤消息格式

單元測試的最佳實踐

命名和組織

實踐 說明
一致的命名 使用描述性名稱,如 test_calculate_discount_with_valid_input
按模塊組織 測試文件結構應反映應用結構,如 test_utils.py 對應 utils.py
測試類分組 相關測試可以組織在測試類中,如 TestUserService
功能分類 按功能或場景分類測試,如 test_validation_cases, test_error_cases

測試設計原則

原則 說明
單一職責 每個測試只測試一個行為或功能點
獨立性 測試之間不應有依賴關係或執行順序要求
確定性 測試結果應該是可預測的,不受環境變化影響
邊界測試 測試邊界條件和極端情況
錯誤案例 不僅測試正常流程,也要測試錯誤處理
代碼覆蓋 確保測試覆蓋所有代碼路徑和分支

AAA 模式

單元測試通常遵循 Arrange-Act-Assert (AAA) 模式:

階段 目的 內容
Arrange 設置測試環境 創建對象、設置參數、準備輸入數據
Act 執行被測試的行為 調用被測函數或方法
Assert 驗證結果 檢查返回值、狀態變化或異常

工具函數的單元測試

工具函數通常是最容易進行單元測試的組件,因為它們往往是純函數(給定相同輸入總是產生相同輸出,沒有副作用)。

測試策略

策略 說明
參數化測試 使用多組輸入/輸出數據測試同一函數
邊界值分析 測試函數在邊界條件下的行為
異常處理 驗證函數對無效輸入的處理
性能檢查 對於關鍵工具函數,可以測試性能表現

示例:日期處理函數測試

假設我們有一個將字符串轉換為日期的工具函數:

# app/utils/date_utils.py
from datetime import datetime, date
from typing import Optional

def parse_date_string(date_str: str) -> Optional[date]:
    """將字符串解析為日期對象,支持多種格式"""
    formats = ["%Y-%m-%d", "%d/%m/%Y", "%Y.%m.%d"]

    for fmt in formats:
        try:
            return datetime.strptime(date_str, fmt).date()
        except ValueError:
            continue

    return None

對應的單元測試:

# tests/utils/test_date_utils.py
import pytest
from datetime import date
from app.utils.date_utils import parse_date_string

@pytest.mark.parametrize("date_str, expected", [
    ("2023-01-15", date(2023, 1, 15)),  # ISO 格式
    ("15/01/2023", date(2023, 1, 15)),  # 歐洲格式
    ("2023.01.15", date(2023, 1, 15)),  # 點分隔格式
    ("invalid-date", None),             # 無效格式
    ("", None),                         # 空字符串
])
def test_parse_date_string(date_str, expected):
    # Act
    result = parse_date_string(date_str)

    # Assert
    assert result == expected

業務邏輯的單元測試

業務邏輯是應用的核心,需要全面的測試覆蓋。

測試策略

策略 說明
場景測試 測試不同業務場景下的邏輯行為
規則驗證 確保業務規則得到正確實施
狀態轉換 測試狀態機或工作流邏輯
依賴模擬 使用 mock 隔離外部依賴

示例:折扣計算服務測試

假設我們有一個計算訂單折扣的服務:

# app/services/discount_service.py
from decimal import Decimal
from typing import Optional

class DiscountService:
    def calculate_discount(
        self, 
        order_total: Decimal, 
        user_tier: str, 
        coupon_code: Optional[str] = None
    ) -> Decimal:
        """計算訂單折扣金額"""
        # 基礎折扣率
        discount_rate = Decimal('0.0')

        # 會員等級折扣
        if user_tier == "gold":
            discount_rate += Decimal('0.05')
        elif user_tier == "platinum":
            discount_rate += Decimal('0.1')

        # 優惠券折扣
        if coupon_code == "SAVE10":
            discount_rate += Decimal('0.1')
        elif coupon_code == "SAVE20":
            discount_rate += Decimal('0.2')

        # 最大折扣率為 30%
        discount_rate = min(discount_rate, Decimal('0.3'))

        return order_total * discount_rate

對應的單元測試:

# tests/services/test_discount_service.py
import pytest
from decimal import Decimal
from app.services.discount_service import DiscountService

class TestDiscountService:
    @pytest.fixture
    def service(self):
        return DiscountService()

    @pytest.mark.parametrize("order_total, user_tier, coupon_code, expected_discount", [
        # 基本測試案例
        (Decimal('100.00'), "regular", None, Decimal('0.00')),
        (Decimal('100.00'), "gold", None, Decimal('5.00')),
        (Decimal('100.00'), "platinum", None, Decimal('10.00')),

        # 優惠券測試
        (Decimal('100.00'), "regular", "SAVE10", Decimal('10.00')),
        (Decimal('100.00'), "regular", "SAVE20", Decimal('20.00')),
        (Decimal('100.00'), "regular", "INVALID", Decimal('0.00')),

        # 組合折扣測試
        (Decimal('100.00'), "gold", "SAVE10", Decimal('15.00')),
        (Decimal('100.00'), "platinum", "SAVE20", Decimal('30.00')),  # 最大折扣率 30%

        # 金額測試
        (Decimal('0.00'), "platinum", "SAVE20", Decimal('0.00')),
        (Decimal('1000.00'), "gold", "SAVE10", Decimal('150.00')),
    ])
    def test_calculate_discount(
        self, service, order_total, user_tier, coupon_code, expected_discount
    ):
        # Act
        discount = service.calculate_discount(order_total, user_tier, coupon_code)

        # Assert
        assert discount == expected_discount

Pydantic 模型的單元測試

Pydantic 模型是 FastAPI 應用的重要組成部分,負責數據驗證和轉換。

測試策略

策略 說明
實例化驗證 測試模型能否正確實例化
字段驗證 測試字段約束和驗證邏輯
默認值 驗證默認值是否正確設置
轉換方法 測試自定義的轉換方法
錯誤處理 驗證無效數據的錯誤信息

示例:用戶模型測試

假設我們有一個用戶模型:

# app/models/user.py
from pydantic import BaseModel, EmailStr, Field, validator
from datetime import date
from typing import Optional

class UserCreate(BaseModel):
    username: str = Field(..., min_length=3, max_length=50)
    email: EmailStr
    birth_date: Optional[date] = None
    password: str = Field(..., min_length=8)
    password_confirm: str

    @validator('password_confirm')
    def passwords_match(cls, v, values):
        if 'password' in values and v != values['password']:
            raise ValueError('密碼不匹配')
        return v

    def to_user_model(self):
        """轉換為用戶模型(不包含確認密碼)"""
        data = self.dict(exclude={'password_confirm'})
        return data

對應的單元測試:

# tests/models/test_user.py
import pytest
from pydantic import ValidationError
from datetime import date
from app.models.user import UserCreate

class TestUserCreateModel:
    def test_valid_user_data(self):
        # Arrange
        user_data = {
            "username": "testuser",
            "email": "test@example.com",
            "birth_date": "1990-01-01",
            "password": "securepass",
            "password_confirm": "securepass"
        }

        # Act
        user = UserCreate(**user_data)

        # Assert
        assert user.username == "testuser"
        assert user.email == "test@example.com"
        assert user.birth_date == date(1990, 1, 1)
        assert user.password == "securepass"

    def test_password_validation(self):
        # Arrange
        user_data = {
            "username": "testuser",
            "email": "test@example.com",
            "password": "securepass",
            "password_confirm": "different"
        }

        # Act & Assert
        with pytest.raises(ValidationError) as exc_info:
            UserCreate(**user_data)

        errors = exc_info.value.errors()
        assert any(error["msg"] == "密碼不匹配" for error in errors)

    def test_username_length_validation(self):
        # Arrange - 用戶名太短
        user_data = {
            "username": "ab",  # 少於最小長度 3
            "email": "test@example.com",
            "password": "securepass",
            "password_confirm": "securepass"
        }

        # Act & Assert
        with pytest.raises(ValidationError) as exc_info:
            UserCreate(**user_data)

        errors = exc_info.value.errors()
        assert any("username" in error["loc"] for error in errors)

    def test_to_user_model(self):
        # Arrange
        user_data = {
            "username": "testuser",
            "email": "test@example.com",
            "birth_date": "1990-01-01",
            "password": "securepass",
            "password_confirm": "securepass"
        }
        user = UserCreate(**user_data)

        # Act
        user_model_data = user.to_user_model()

        # Assert
        assert "password_confirm" not in user_model_data
        assert user_model_data["username"] == "testuser"
        assert user_model_data["email"] == "test@example.com"
        assert user_model_data["birth_date"] == date(1990, 1, 1)
        assert user_model_data["password"] == "securepass"

依賴函數的單元測試

依賴函數是 FastAPI 的重要特性,用於路由之間共享代碼和邏輯。

測試策略

策略 說明
獨立測試 將依賴函數視為普通函數進行測試
模擬請求上下文 模擬依賴函數在請求中的行為
異常處理 測試依賴函數的錯誤處理邏輯
返回值驗證 確保依賴函數返回正確的值

示例:簡單認證依賴測試

假設我們有一個簡單的認證依賴函數:

# app/dependencies/auth.py
from fastapi import Header, HTTPException, status
from typing import Optional

def get_api_key(api_key: Optional[str] = Header(None)) -> str:
    """驗證 API 金鑰"""
    if api_key is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="缺少 API 金鑰"
        )

    # 在實際應用中,這裡會檢查數據庫或配置
    valid_api_keys = ["test_key", "dev_key", "prod_key"]

    if api_key not in valid_api_keys:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="無效的 API 金鑰"
        )

    return api_key

對應的單元測試:

# tests/dependencies/test_auth.py
import pytest
from fastapi import HTTPException
from app.dependencies.auth import get_api_key

def test_get_api_key_valid():
    # Arrange
    valid_key = "test_key"

    # Act
    result = get_api_key(valid_key)

    # Assert
    assert result == valid_key

def test_get_api_key_missing():
    # Arrange & Act & Assert
    with pytest.raises(HTTPException) as exc_info:
        get_api_key(None)

    assert exc_info.value.status_code == 401
    assert "缺少 API 金鑰" in exc_info.value.detail

def test_get_api_key_invalid():
    # Arrange
    invalid_key = "invalid_key"

    # Act & Assert
    with pytest.raises(HTTPException) as exc_info:
        get_api_key(invalid_key)

    assert exc_info.value.status_code == 403
    assert "無效的 API 金鑰" in exc_info.value.detail

使用模擬 (Mock) 進行單元測試

在單元測試中,我們經常需要隔離外部依賴,如數據庫、外部 API 或文件系統。Python 的 unittest.mock 模塊提供了強大的模擬功能。

常用模擬技術

技術 用途 適用場景
patch 裝飾器 替換模塊中的對象 模擬導入的函數或類
MagicMock 創建具有特定行為的模擬對象 模擬複雜對象和方法鏈
side_effect 定義模擬調用的行為 模擬異常或動態返回值
return_value 設置模擬調用的返回值 簡單的返回值模擬
assert_called_with 驗證模擬是否使用特定參數調用 驗證函數調用參數

示例:使用模擬的服務測試

假設我們有一個使用外部服務的產品服務:

# app/services/product_service.py
from typing import Dict, List, Any
from app.repositories.product_repository import ProductRepository

class ProductService:
    def __init__(self, product_repo: ProductRepository):
        self.product_repo = product_repo

    def get_product_by_id(self, product_id: int) -> Dict[str, Any]:
        """根據 ID 獲取產品"""
        product = self.product_repo.get_by_id(product_id)
        if not product:
            raise ValueError(f"產品不存在: {product_id}")
        return product

    def get_products_by_category(self, category: str) -> List[Dict[str, Any]]:
        """獲取指定類別的所有產品"""
        return self.product_repo.get_by_category(category)

    def search_products(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
        """搜索產品"""
        if not query or len(query) < 3:
            raise ValueError("搜索查詢必須至少包含 3 個字符")

        return self.product_repo.search(query, limit)

對應的單元測試:

# tests/services/test_product_service.py
import pytest
from unittest.mock import Mock
from app.services.product_service import ProductService

@pytest.fixture
def mock_product_repo():
    return Mock()

@pytest.fixture
def product_service(mock_product_repo):
    return ProductService(mock_product_repo)

def test_get_product_by_id_success(product_service, mock_product_repo):
    # Arrange
    product_id = 1
    expected_product = {"id": product_id, "name": "測試產品", "price": 99.99}
    mock_product_repo.get_by_id.return_value = expected_product

    # Act
    result = product_service.get_product_by_id(product_id)

    # Assert
    assert result == expected_product
    mock_product_repo.get_by_id.assert_called_once_with(product_id)

def test_get_product_by_id_not_found(product_service, mock_product_repo):
    # Arrange
    product_id = 999
    mock_product_repo.get_by_id.return_value = None

    # Act & Assert
    with pytest.raises(ValueError) as exc_info:
        product_service.get_product_by_id(product_id)

    assert f"產品不存在: {product_id}" in str(exc_info.value)
    mock_product_repo.get_by_id.assert_called_once_with(product_id)

def test_get_products_by_category(product_service, mock_product_repo):
    # Arrange
    category = "電子產品"
    expected_products = [
        {"id": 1, "name": "手機", "category": category},
        {"id": 2, "name": "平板電腦", "category": category}
    ]
    mock_product_repo.get_by_category.return_value = expected_products

    # Act
    result = product_service.get_products_by_category(category)

    # Assert
    assert result == expected_products
    mock_product_repo.get_by_category.assert_called_once_with(category)

def test_search_products_valid_query(product_service, mock_product_repo):
    # Arrange
    query = "手機"
    limit = 5
    expected_results = [{"id": 1, "name": "智能手機"}]
    mock_product_repo.search.return_value = expected_results

    # Act
    result = product_service.search_products(query, limit)

    # Assert
    assert result == expected_results
    mock_product_repo.search.assert_called_once_with(query, limit)

def test_search_products_invalid_query(product_service, mock_product_repo):
    # Arrange
    query = "ab"  # 少於 3 個字符

    # Act & Assert
    with pytest.raises(ValueError) as exc_info:
        product_service.search_products(query)

    assert "搜索查詢必須至少包含 3 個字符" in str(exc_info.value)
    mock_product_repo.search.assert_not_called()

單元測試的常見陷阱與解決方案

陷阱 問題 解決方案
過度模擬 過多的模擬使測試變得脆弱且難以維護 只模擬外部依賴,不模擬被測系統的內部
測試實現而非行為 測試依賴於代碼的具體實現 專注於測試公共 API 和可觀察行為
測試覆蓋率迷思 過分追求高覆蓋率而忽略測試質量 關注關鍵路徑和邊界條件的測試
忽略邊界條件 只測試正常情況,忽略極端情況 系統地識別和測試邊界條件
測試不穩定 測試結果不一致或依賴環境 確保測試的確定性和獨立性
測試過於複雜 測試代碼比被測代碼更複雜 保持測試簡單,一個測試只測一個行為

總結

單元測試是 FastAPI 應用程序測試策略的基礎。通過有效地測試工具函數、業務邏輯、Pydantic 模型和依賴函數,你可以確保應用程序的核心組件按預期工作。

單元測試要點

方面 關鍵點
測試範圍 專注於測試最小的獨立代碼單元
確保測試的隔離性和確定性
測試設計 遵循 AAA 模式組織測試
使用參數化測試減少重複
依賴處理 使用模擬隔離外部依賴
驗證與依賴的交互
測試覆蓋 確保測試覆蓋正常流程和錯誤情況
關注邊界條件和極端情況
代碼質量 保持測試代碼的簡潔和可讀性
避免測試代碼中的邏輯複雜性

通過良好的單元測試實踐,你可以建立對代碼的信心,使重構和新功能開發變得更加安全和高效。記住,單元測試不僅是捕獲錯誤的工具,也是代碼設計的指南。