iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0

你有沒有遇過這種情況? 🤔

「為什麼我的測試總是跑不過?明明程式碼都沒問題啊!」經過一番調查,發現是因為他的測試環境設置不一致。這讓我想到,如果能在測試的關鍵時刻自動執行一些設置或清理工作,是不是就能避免這種問題了?

今天我們要學習 pytest 的測試生命週期 Hook 功能,確保測試環境的一致性!

本日學習地圖 🗺️

基礎篇          Kata 篇           框架篇
Day 1-10        Day 11-17        Day 18-24 ← 我們在這裡!
[=====]         [=====]          [=====>]

測試生命週期管理
├── 🔄 pytest_runtest_setup/teardown
├── 📦 pytest_sessionstart/finish  
├── ⚡ 效能監控 Hook
└── 🎯 全域 Hook 配置

為什麼需要測試生命週期 Hook? 🎯

測試生命週期 Hook 是在測試執行的特定時機點自動觸發的程式碼。它們幫助我們管理測試環境,確保每個測試都在相同的條件下執行。

測試挑戰

  • 環境不一致 - 測試間相互影響
  • 重複設置 - 每個測試都要寫相同程式碼
  • 資源清理 - 忘記清理導致測試失敗

使用生命週期 Hook 📦

pytest 提供了生命週期 Hook 來管理測試環境。

# 建立 conftest.py
import time
import pytest
from typing import Any

def pytest_runtest_setup(item: Any) -> None:
    """每個測試執行前的 Hook"""
    print(f"\n🚀 開始測試: {item.name}")
    item.start_time = time.time()

def pytest_runtest_teardown(item: Any) -> None:
    """每個測試執行後的 Hook"""
    duration = time.time() - getattr(item, 'start_time', 0)
    print(f"⏱️ 執行時間: {duration:.3f} 秒")

建立 conftest.py

# 建立 tests/day24/test_hook_lifecycle.py
def test_demonstrate_hooks():
    """展示 Hook 執行順序"""
    print("執行測試中...")
    assert True

# 更新 conftest.py
def pytest_sessionstart(session):
    """測試會話開始"""
    print("\n📦 測試會話開始")

def pytest_sessionfinish(session, exitstatus):
    """測試會話結束"""
    print(f"\n✅ 測試完成,狀態碼: {exitstatus}")

def pytest_runtest_makereport(item, call):
    """產生測試報告"""
    if call.when == "call":
        outcome = "通過" if call.excinfo is None else "失敗"
        print(f"📊 測試 {item.name} - {outcome}")

管理測試效能監控 🔄

# 建立 tests/day24/performance_plugin.py
from typing import Dict, List

class PerformanceMonitor:
    """效能監控器"""
    
    def __init__(self):
        self.results: Dict[str, float] = {}
        self.slow_tests: List[tuple] = []
        self.threshold = 1.0  # 慢測試閾值(秒)
    
    def record_duration(self, test_name: str, duration: float):
        """記錄測試執行時間"""
        self.results[test_name] = duration
        if duration > self.threshold:
            self.slow_tests.append((test_name, duration))
    
    def generate_report(self) -> str:
        """產生效能報告"""
        if not self.results:
            return "沒有測試執行"
        
        avg_time = sum(self.results.values()) / len(self.results)
        report = [
            "\n" + "="*50,
            f"📊 總測試數: {len(self.results)}",
            f"⏱️ 平均時間: {avg_time:.3f} 秒",
        ]
        
        if self.slow_tests:
            report.append("\n⚠️ 慢速測試:")
            for name, duration in self.slow_tests:
                report.append(f"  - {name}: {duration:.3f} 秒")
        
        return "\n".join(report)

# 更新 conftest.py
def pytest_configure(config):
    """配置 pytest"""
    config.performance_monitor = PerformanceMonitor()

def pytest_runtest_teardown(item):
    """測試結束後記錄時間"""
    duration = time.time() - getattr(item, 'start_time', 0)
    monitor = item.config.performance_monitor
    monitor.record_duration(item.nodeid, duration)

def pytest_terminal_summary(terminalreporter, config):
    """測試會話結束時輸出報告"""
    monitor = config.performance_monitor
    print(monitor.generate_report())

測試失敗重試機制 ⚡

# 建立 tests/day24/retry_plugin.py
import pytest
from typing import Dict

class RetryPlugin:
    """重試插件"""
    
    def __init__(self, max_retries: int = 3):
        self.max_retries = max_retries
        self.retry_count: Dict[str, int] = {}
    
    @pytest.hookimpl(tryfirst=True, hookwrapper=True)
    def pytest_runtest_makereport(self, item, call):
        """處理測試報告"""
        outcome = yield
        report = outcome.get_result()
        
        if report.when == "call" and report.failed:
            test_id = item.nodeid
            
            if test_id not in self.retry_count:
                self.retry_count[test_id] = 0
            
            if self.retry_count[test_id] < self.max_retries:
                self.retry_count[test_id] += 1
                print(f"\n🔄 重試測試 (第 {self.retry_count[test_id]} 次)")
                report.outcome = "passed"

測試資源清理 🧹

# 建立 tests/day24/cleanup_plugin.py
from pathlib import Path
from typing import Set

class CleanupManager:
    """清理管理器"""
    
    def __init__(self, test_dir: str = "test_output"):
        self.test_dir = Path(test_dir)
        self.created_files: Set[Path] = set()
    
    def register_file(self, filepath: Path):
        """註冊需要清理的檔案"""
        self.created_files.add(filepath)
    
    def cleanup(self):
        """執行清理"""
        for filepath in self.created_files:
            if filepath.exists():
                filepath.unlink()
                print(f"🗑️ 刪除檔案: {filepath}")

# Hook 實作
def pytest_configure(config):
    """初始化清理管理器"""
    config.cleanup_manager = CleanupManager()

def pytest_sessionfinish(session):
    """會話結束時執行清理"""
    session.config.cleanup_manager.cleanup()

測試標記處理 🎯

# 建立 tests/day24/marker_plugin.py
import pytest

def pytest_configure(config):
    """註冊自定義標記"""
    config.addinivalue_line("markers", "critical: 標記關鍵測試")
    config.addinivalue_line("markers", "slow: 標記慢速測試")

def pytest_runtest_setup(item):
    """根據標記設置測試"""
    markers = {marker.name for marker in item.iter_markers()}
    
    if "critical" in markers:
        print(f"\n⚠️ 執行關鍵測試: {item.name}")
    
    if "slow" in markers:
        print(f"\n🐌 執行慢速測試: {item.name}")

# 測試範例
# tests/day24/test_with_markers.py
@pytest.mark.critical
def test_critical_feature():
    """關鍵功能測試"""
    assert True

@pytest.mark.slow
def test_slow_operation():
    """慢速操作測試"""
    import time
    time.sleep(0.5)
    assert True

測試執行時間監控 ⏱️

# 建立 tests/day24/report_generator.py
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Any

class TestReportGenerator:
    """測試報告生成器"""
    
    def __init__(self, output_dir: str = "test_reports"):
        self.output_dir = Path(output_dir)
        self.output_dir.mkdir(exist_ok=True)
        self.test_results: List[Dict[str, Any]] = []
        self.session_data: Dict[str, Any] = {}
    
    def start_session(self):
        """開始測試會話"""
        self.session_data = {
            "start_time": datetime.now().isoformat(),
            "test_count": 0,
            "passed": 0,
            "failed": 0,
            "skipped": 0
        }
    
    def add_test_result(self, test_data: Dict[str, Any]):
        """添加測試結果"""
        self.test_results.append(test_data)
        self.session_data["test_count"] += 1
        
        status = test_data["outcome"]
        if status in ["passed", "failed", "skipped"]:
            self.session_data[status] += 1
    
    def generate_report(self):
        """生成文字報告"""
        report = [
            "\n" + "="*50,
            "📊 測試執行報告",
            "="*50,
            f"總測試數: {self.session_data.get('test_count', 0)}",
            f"✅ 通過: {self.session_data.get('passed', 0)}",
            f"❌ 失敗: {self.session_data.get('failed', 0)}",
        ]
        return "\n".join(report)

# 完整的 Hook 整合
# 更新 conftest.py
def pytest_configure(config):
    """配置報告生成器"""
    config.report_generator = TestReportGenerator()

def pytest_sessionstart(session):
    """開始測試會話"""
    session.config.report_generator.start_session()

def pytest_runtest_makereport(item, call):
    """收集測試結果"""
    if call.when == "call":
        generator = item.config.report_generator
        test_data = {
            "name": item.nodeid,
            "outcome": "passed" if call.excinfo is None else "failed",
            "duration": call.duration,
        }
        generator.add_test_result(test_data)

def pytest_sessionfinish(session):
    """生成最終報告"""
    generator = session.config.report_generator
    print(generator.generate_report())

測試練習題 🏋️‍♂️

試著建立快取管理 Hook,思考測試要點:清空測試快取、記錄快取使用、警告快取未命中、確保測試隔離。

本日重點回顧 📝

今天我們學習了如何管理測試生命週期:

核心概念

  • pytest_runtest_setup/teardown - 每個測試的設置與清理
  • pytest_sessionstart/finish - 測試會話的設置與清理
  • 效能監控 - 追蹤測試執行時間
  • 資源管理 - 自動清理測試資源

測試技巧

✅ 效能監控、失敗重試、資源清理、標記處理

延伸思考 💭

  • 如何設計通用的測試設置?
  • 如何避免 Hook 之間的依賴?
  • 如何確保 Hook 的執行效能?

記住:好的生命週期管理讓測試更穩定可靠! 💪


上一篇
Day 23 - 測試篩選與路由 🎯
系列文
Python pytest TDD 實戰:從零開始的測試驅動開發24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言