iT邦幫忙

2025 iThome 鐵人賽

DAY 5
0

今天要做什麼?

昨天我們學會了測試結構與組織,但隨著測試越寫越多,你可能遇到一個問題:「為什麼這個測試單獨執行會通過,但和其他測試一起執行時會失敗?」

想像一個場景:你為數學工具庫新增了一個 CalculatorWithHistory 類別,它會記錄計算歷史。第一個測試執行時歷史是空的,測試通過;但第二個測試執行時,歷史裡已經有第一個測試留下的資料,導致測試失敗。這就是「測試污染」問題。

今天我們要學習「測試生命週期」,了解如何在每個測試執行前後進行適當的設置和清理,讓每個測試都能在乾淨、一致的環境中執行。

學習目標

今天結束後,你將學會:

  • 理解測試生命週期的重要性
  • 掌握 setup/teardown 方法的使用
  • 學會測試資料的設置和清理
  • 理解測試隔離的概念

TDD 學習地圖

第一階段:打好基礎(Day 1-10)
├── Day 01 - 環境設置與第一個測試
├── Day 02 - 認識斷言(Assertions)
├── Day 03 - TDD 紅綠重構循環
├── Day 04 - 測試結構與組織
├── Day 05 - 測試生命週期 ★ 今天在這裡
├── ...
└── (更多精彩內容待續)

什麼是測試生命週期? 🔄

測試執行的階段

每個測試的執行都會經過以下階段:

設置階段 → 執行階段 → 斷言階段 → 清理階段
(Setup)   (Execute)  (Assert)   (Cleanup)
  • 設置階段:準備測試需要的資料和環境
  • 執行階段:呼叫要測試的功能
  • 斷言階段:驗證結果是否符合預期
  • 清理階段:清理測試產生的副作用

沒有生命週期管理的問題

讓我們先看看沒有適當生命週期管理的測試:

# 問題:測試之間會互相影響
calculator = CalculatorWithHistory()

def test_add_operation():
    result = calculator.add(2, 3)
    assert result == 5
    assert len(calculator.get_history()) == 1

def test_multiply_operation():
    result = calculator.multiply(4, 5)
    assert result == 20
    assert len(calculator.get_history()) == 1  # ❌ 實際是 2!

第二個測試會失敗,因為計算器的歷史記錄還保留著第一個測試的資料。

測試隔離的重要性 🔒

什麼是測試隔離?

測試隔離是指每個測試案例都應該:

  • 獨立執行:不依賴其他測試的執行結果
  • 環境一致:每次執行都有相同的初始狀態
  • 無副作用:執行後不影響其他測試

使用 setup_method 解決問題

# ✅ 好的測試:每個測試都是獨立的
class TestCalculatorWithHistory:
    def setup_method(self):
        self.calculator = CalculatorWithHistory()
    
    def test_add_operation(self):
        result = self.calculator.add(2, 3)
        assert result == 5
        assert len(self.calculator.get_history()) == 1
    
    def test_multiply_operation(self):
        result = self.calculator.multiply(4, 5)
        assert result == 20
        assert len(self.calculator.get_history()) == 1  # 現在會通過

setup_method 的使用 🚀

什麼是 setup_method?

setup_method 是在每個測試方法執行「之前」都會執行的方法,用來設置測試環境。

實戰演練:建立 CalculatorWithHistory

建立 src/math/calculator_with_history.py

from typing import List, Dict, Union, Optional

class CalculatorWithHistory:
    def __init__(self):
        self._history: List[Dict[str, Union[str, List[float], float]]] = []
    
    def add(self, a: float, b: float) -> float:
        result = a + b
        self._record_operation('add', [a, b], result)
        return result
    
    def multiply(self, a: float, b: float) -> float:
        result = a * b
        self._record_operation('multiply', [a, b], result)
        return result
    
    def get_history(self) -> List[Dict[str, Union[str, List[float], float]]]:
        return self._history.copy()
    
    def get_last_result(self) -> Optional[float]:
        return self._history[-1]['result'] if self._history else None
    
    def clear_history(self) -> None:
        self._history = []
    
    def _record_operation(self, operation: str, operands: List[float], result: float) -> None:
        self._history.append({
            'operation': operation,
            'operands': operands,
            'result': result
        })

建立 tests/day05/test_calculator_lifecycle.py

import pytest
from src.math.calculator_with_history import CalculatorWithHistory

class TestCalculatorWithHistory:
    def setup_method(self):
        """每個測試開始前都創建一個全新的計算器"""
        self.calculator = CalculatorWithHistory()

    def test_add(self):
        result = self.calculator.add(2, 3)
        
        assert result == 5
        assert len(self.calculator.get_history()) == 1
        assert self.calculator.get_last_result() == 5

    def test_multiply(self):
        result = self.calculator.multiply(4, 5)
        
        assert result == 20
        assert len(self.calculator.get_history()) == 1  # 現在每個測試都是乾淨的
        assert self.calculator.get_last_result() == 20

    def test_record_single_operation(self):
        self.calculator.add(2, 3)
        
        history = self.calculator.get_history()
        assert len(history) == 1
        assert history[0]['operation'] == 'add'
        assert history[0]['operands'] == [2, 3]
        assert history[0]['result'] == 5

    def test_record_multiple_operations(self):
        self.calculator.add(2, 3)
        self.calculator.multiply(4, 5)
        
        history = self.calculator.get_history()
        assert len(history) == 2
        assert history[0]['operation'] == 'add'
        assert history[1]['operation'] == 'multiply'

    def test_clear_history(self):
        self.calculator.add(2, 3)
        self.calculator.multiply(4, 5)
        assert len(self.calculator.get_history()) == 2
        
        self.calculator.clear_history()
        assert len(self.calculator.get_history()) == 0
        assert self.calculator.get_last_result() is None

teardown_method 的使用 🧹

什麼是 teardown_method?

teardown_method 是在每個測試方法執行「之後」都會執行的方法,用來清理測試環境。

實戰演練:需要清理的場景

class TestResourceCleanup:
    def setup_method(self):
        self.resource = {'active': False, 'data': []}
    
    def teardown_method(self):
        """確保每個測試後都清理資源"""
        self.resource['active'] = False
        self.resource['data'] = []

    def test_uses_resource(self):
        self.resource['active'] = True
        self.resource['data'].append('test')
        
        assert self.resource['active'] is True
        assert len(self.resource['data']) == 1

pytest fixtures 的使用 🛠️

使用 fixtures 進行設置

pytest 提供了更靈活的 fixtures 機制:

import pytest
from src.math.calculator_with_history import CalculatorWithHistory

@pytest.fixture
def calculator():
    """為每個測試提供乾淨的計算器"""
    return CalculatorWithHistory()

@pytest.fixture
def calculator_with_history():
    """為每個測試提供有預設歷史的計算器"""
    calc = CalculatorWithHistory()
    calc.add(1, 1)      # 2
    calc.multiply(3, 4) # 12
    return calc

def test_add_with_fixture(calculator):
    result = calculator.add(2, 3)
    assert result == 5
    assert len(calculator.get_history()) == 1

def test_multiply_with_fixture(calculator):
    result = calculator.multiply(4, 5)
    assert result == 20
    assert len(calculator.get_history()) == 1

def test_check_default_history_count(calculator_with_history):
    assert len(calculator_with_history.get_history()) == 2

def test_check_last_result(calculator_with_history):
    assert calculator_with_history.get_last_result() == 12

def test_add_operation_increases_history(calculator_with_history):
    calculator_with_history.add(5, 5)
    assert len(calculator_with_history.get_history()) == 3

資源管理最佳實踐 💡

對稱的設置與清理

始終保持設置(setup)和清理(cleanup)的對稱性:

class TestResourceManagement:
    def setup_method(self):
        self.resource = SomeResource()
        self.resource.initialize()
    
    def teardown_method(self):
        self.resource.cleanup()

避免常見陷阱 ⚠️

常見錯誤 ❌

  1. 忘記清理資源:可能導致記憶體洩漏或測試污染
  2. 設置順序錯誤:先初始化再建立物件會導致錯誤
  3. 過度設置:設置不必要的資源浪費時間

今天學到什麼? 📚

今天我們深入學習了測試生命週期的重要概念:

核心概念

  • 測試生命週期:設置 → 執行 → 斷言 → 清理的完整流程
  • 測試隔離:每個測試獨立執行,互不影響
  • setup_method/teardown_method:自動化的設置和清理機制
  • pytest fixtures:更靈活的測試資料管理

實用技巧

  • 對稱設置:設置什麼就清理什麼
  • 最小設置:只設置必要的資源
  • 正確順序:先創建資源再初始化

避免的陷阱

  • 忘記清理:導致資源洩露或測試污染
  • 設置順序錯誤:導致初始化失敗

總結 🎯

測試生命週期是確保測試穩定性和可靠性的基礎。通過適當的設置和清理:

  • 提高測試穩定性:每個測試都在乾淨環境中執行
  • 防止測試污染:測試之間不會互相影響
  • 改善偵錯體驗:失敗的測試更容易定位問題

記住:良好的測試生命週期管理是可靠測試的基石。

明天我們將學習「參數化測試」,了解如何用同一個測試邏輯驗證多組不同的資料,讓測試更加高效和全面。


上一篇
Day 04 - 測試結構與組織 🚀
下一篇
Day 06 - 參數化測試 🔢
系列文
Python pytest TDD 實戰:從零開始的測試驅動開發8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言