iT邦幫忙

2025 iThome 鐵人賽

DAY 26
0

「系統上線後,用戶抱怨:『為什麼載入要等這麼久?』」這是每個開發者的惡夢。今天,我們將學習如何在開發階段就發現並解決效能問題,透過 TDD 的方式確保程式碼不僅正確,還要夠快!

🗺️ 我們的測試旅程

基礎測試 ✅ → 實戰專案 ✅ → 框架測試 ✅ → 【效能測試 📍】→ 最後階段

經過 25 天的學習,我們已經掌握了功能測試的精髓。今天要進入效能測試的世界,學習如何量化程式碼的執行速度,並在 TDD 過程中確保效能符合預期。

為什麼需要效能測試?

效能問題的代價

  • 用戶流失:載入超過 3 秒,53% 的用戶會離開
  • 成本增加:效能差需要更多硬體資源
  • 技術債累積:越晚發現越難修復

TDD 與效能測試的結合

# 傳統測試:只確保功能正確
def test_search_returns_results():
    results = search("python")
    assert len(results) > 0

# 效能測試:還要確保速度夠快
def test_search_performance():
    import time
    start = time.time()
    results = search("python")
    duration = time.time() - start
    assert duration < 1.0  # 必須在 1 秒內完成

🔬 Python 效能測試工具

1. 內建 time 模組

最簡單的計時方法:

# 建立 tests/day26/test_basic_timing.py
import time
import pytest

def slow_function():
    """模擬耗時操作"""
    time.sleep(0.1)
    return "done"

def test_function_speed():
    start = time.perf_counter()
    result = slow_function()
    duration = time.perf_counter() - start
    
    assert result == "done"
    assert duration < 0.2  # 容許誤差

2. pytest-benchmark 外掛

專業的效能測試框架:

# 安裝
pip install pytest-benchmark
# 建立 tests/day26/test_benchmark.py
import pytest

def fibonacci(n):
    """計算費氏數列"""
    if n < 2:
        return n
    return fibonacci(n-1) + fibonacci(n-2)

def test_fibonacci_benchmark(benchmark):
    # benchmark 會自動執行多次並統計
    result = benchmark(fibonacci, 20)
    assert result == 6765

執行測試會看到詳細報告:

------------------------- benchmark: 1 tests -------------------------
Name                       Min       Max      Mean     StdDev    Median
test_fibonacci_benchmark  1.2345   1.5678    1.3456    0.0234   1.3400

🏗️ 實戰案例:待辦事項 API 效能優化

步驟 1:建立基準測試

# 建立 tests/day26/test_todo_performance.py
import pytest
import time
from typing import List
from dataclasses import dataclass

@dataclass(frozen=True)  # frozen=True 使 Todo 可以被 hash
class Todo:
    id: int
    title: str
    completed: bool = False

class TodoService:
    def __init__(self):
        self.todos: List[Todo] = []
    
    def search_todos(self, keyword: str) -> List[Todo]:
        """簡單的線性搜尋"""
        results = []
        for todo in self.todos:
            if keyword.lower() in todo.title.lower():
                results.append(todo)
        return results

def test_search_performance_baseline():
    """測試:搜尋 1000 筆資料應在 0.1 秒內完成"""
    service = TodoService()
    
    # 準備測試資料
    for i in range(1000):
        service.todos.append(
            Todo(id=i, title=f"Task {i % 100}")
        )
    
    # 執行搜尋並計時
    start = time.perf_counter()
    results = service.search_todos("Task 50")
    duration = time.perf_counter() - start
    
    assert len(results) == 10  # 應找到 10 筆
    assert duration < 0.1  # 應在 0.1 秒內完成

步驟 2:發現效能瓶頸

# 更新 tests/day26/test_todo_performance.py
def test_search_performance_large_dataset():
    """測試:搜尋 10000 筆資料的效能"""
    service = TodoService()
    
    # 準備大量測試資料
    for i in range(10000):
        service.todos.append(
            Todo(id=i, title=f"Task {i % 100}")
        )
    
    start = time.perf_counter()
    results = service.search_todos("Task 99")
    duration = time.perf_counter() - start
    
    print(f"搜尋 10000 筆耗時:{duration:.3f} 秒")
    assert duration < 1.0  # 可能會失敗!

步驟 3:效能優化 - 使用索引

# 建立 tests/day26/test_todo_performance.py
from collections import defaultdict

class OptimizedTodoService:
    def __init__(self):
        self.todos: List[Todo] = []
        self.title_index = defaultdict(list)  # 標題索引
    
    def add_todo(self, todo: Todo):
        self.todos.append(todo)
        # 建立索引
        words = todo.title.lower().split()
        for word in words:
            self.title_index[word].append(todo)
    
    def search_todos(self, keyword: str) -> List[Todo]:
        """使用索引加速搜尋"""
        keyword = keyword.lower()
        results = set()
        
        # 從索引中查找
        for word, todos in self.title_index.items():
            if keyword in word:
                results.update(todos)
        
        return list(results)

def test_optimized_search_performance():
    """測試:優化後的搜尋效能"""
    service = OptimizedTodoService()
    
    # 準備測試資料
    for i in range(10000):
        todo = Todo(id=i, title=f"Task {i % 100}")
        service.add_todo(todo)
    
    start = time.perf_counter()
    results = service.search_todos("Task")
    duration = time.perf_counter() - start
    
    print(f"優化後搜尋耗時:{duration:.3f} 秒")
    assert duration < 0.5  # 效能提升!

📊 效能基準與監控

使用 pytest-benchmark 進行對比

# 建立 tests/day26/test_benchmark_comparison.py
import pytest
from typing import List

def linear_search(items: List[int], target: int) -> int:
    """線性搜尋"""
    for i, item in enumerate(items):
        if item == target:
            return i
    return -1

def binary_search(items: List[int], target: int) -> int:
    """二分搜尋(需要排序)"""
    left, right = 0, len(items) - 1
    while left <= right:
        mid = (left + right) // 2
        if items[mid] == target:
            return mid
        elif items[mid] < target:
            left = mid + 1
        else:
            right = mid - 1
    return -1

@pytest.fixture
def test_data():
    return list(range(10000))

def test_linear_search_benchmark(benchmark, test_data):
    result = benchmark(linear_search, test_data, 9999)
    assert result == 9999

def test_binary_search_benchmark(benchmark, test_data):
    result = benchmark(binary_search, test_data, 9999)
    assert result == 9999

🎭 記憶體效能測試

# 建立 tests/day26/test_memory_performance.py
import tracemalloc

def test_memory_usage():
    """測試記憶體使用量"""
    tracemalloc.start()
    
    # 執行操作
    large_list = [i for i in range(1_000_000)]
    
    current, peak = tracemalloc.get_traced_memory()
    tracemalloc.stop()
    
    peak_mb = peak / 1024 / 1024
    print(f"尖峰記憶體:{peak_mb:.2f} MB")
    assert peak_mb < 100  # 不超過 100 MB

非同步效能測試

# 建立 tests/day26/test_async_performance.py
import pytest
import asyncio
import time

async def fetch_sequential():
    """循序執行三次 0.1 秒操作"""
    for _ in range(3):
        await asyncio.sleep(0.1)

async def fetch_concurrent():
    """並行執行三次 0.1 秒操作"""
    await asyncio.gather(*[asyncio.sleep(0.1) for _ in range(3)])

@pytest.mark.asyncio
async def test_concurrent_faster():
    """測試並行比循序快"""
    # 循序:約 0.3 秒
    start = time.perf_counter()
    await fetch_sequential()
    seq_time = time.perf_counter() - start
    
    # 並行:約 0.1 秒
    start = time.perf_counter()
    await fetch_concurrent()
    con_time = time.perf_counter() - start
    
    assert con_time < seq_time * 0.5  # 至少快 2 倍

常見效能陷阱

N+1 查詢問題

# ❌ 錯誤:多次查詢
for todo in todos:
    todo.tags = get_tags_by_id(todo.id)  # N 次

# ✅ 正確:批次查詢  
tags = get_tags_by_ids([t.id for t in todos])  # 1 次

效能測試最佳實踐

1. 設定合理的基準

def test_reasonable_performance():
    """根據需求設定合理期望"""
    # ❌ 太嚴格
    assert response_time < 0.001  
    
    # ✅ 合理的期望
    assert response_time < 1.0  # 一般 API
    assert response_time < 0.1  # 快取查詢

2. 隔離測試環境

@pytest.fixture
def isolated_service():
    """確保每次測試都是乾淨的環境"""
    service = TodoService()
    yield service
    service.clear()  # 清理

3. 使用統計方法

def test_statistical_performance(benchmark):
    """使用統計確保穩定性"""
    results = benchmark.stats
    assert results['median'] < 0.1
    assert results['stddev'] < 0.01  # 變異要小

完整實作:效能監控系統

# 完整實作 tests/day26/test_performance_monitor.py
import time
import statistics
from typing import Callable

def measure_performance(func: Callable, *args, runs: int = 10) -> dict:
    """測量函數效能"""
    times = []
    for _ in range(runs):
        start = time.perf_counter()
        func(*args)
        times.append(time.perf_counter() - start)
    
    return {
        'min': min(times),
        'max': max(times),
        'mean': statistics.mean(times),
        'median': statistics.median(times),
        'stddev': statistics.stdev(times) if len(times) > 1 else 0
    }

def test_performance_monitor():
    """測試效能監控系統"""
    def sample_function(n: int):
        return sum(range(n))
    
    # 測量效能
    result = measure_performance(sample_function, 10000, runs=20)
    
    # 驗證結果
    assert result['median'] < 0.01
    assert result['stddev'] < result['mean'] * 0.1
    
    print(f"效能:中位數 {result['median']:.4f}s")

小挑戰

試試看這些進階效能測試:

  1. 測試分頁效能:確保分頁查詢的時間複雜度是 O(1)
  2. 測試搜尋效能:加入全文搜尋索引並測試效能提升
  3. 測試批次操作:測試批次更新 1000 筆資料的效能

為搜尋功能加入快取,測試效能提升:

# 挑戰:加入 @lru_cache 裝飾器
from functools import lru_cache

@lru_cache(maxsize=128)
def expensive_search(keyword: str) -> List:
    # 你的實作...
    pass

def test_cache_performance():
    # 第一次:慢
    start = time.perf_counter()
    result1 = expensive_search("test")
    time1 = time.perf_counter() - start
    
    # 第二次:快(從快取)
    start = time.perf_counter()
    result2 = expensive_search("test")
    time2 = time.perf_counter() - start
    
    assert time2 < time1 * 0.1

重點整理

今天我們學習了效能測試的核心技術:

  • ✅ 使用 time 模組進行基本計時
  • ✅ 運用 pytest-benchmark 進行專業測試
  • ✅ 實作了待辦事項 API 的效能優化
  • ✅ 掌握了記憶體和非同步效能測試

效能測試不是錦上添花,而是確保系統品質的必要環節。透過 TDD 的方式,我們可以在開發初期就建立效能基準,避免後期的效能災難。

明天是 Day 27,我們將進入測試的最後階段,完成 TDD 旅程的最後一塊拼圖!

下一步挑戰

嘗試為你的專案加入效能測試:

  • 找出最慢的函數
  • 設定效能基準線
  • 逐步優化並用測試驗證改善

記住:「快」不是偶然,是刻意設計和持續測試的結果!

深度思考

  1. 效能 vs 可讀性:如何在兩者間取得平衡?
  2. 過早優化:什麼時候該開始關注效能?
  3. 效能退化:如何防止新功能影響既有效能?

上一篇
Day 25 - 整合測試 🔗
下一篇
Day 27 - E2E 測試預覽 🎬
系列文
Python pytest TDD 實戰:從零開始的測試驅動開發29
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言