昨天我們學會了測試結構與組織,但隨著測試越寫越多,你可能遇到一個問題:「為什麼這個測試單獨執行會通過,但和其他測試一起執行時會失敗?」
想像一個場景:你為數學工具庫新增了一個 CalculatorWithHistory
類別,它會記錄計算歷史。第一個測試執行時歷史是空的,測試通過;但第二個測試執行時,歷史裡已經有第一個測試留下的資料,導致測試失敗。這就是「測試污染」問題。
今天我們要學習「測試生命週期」,了解如何在每個測試執行前後進行適當的設置和清理,讓每個測試都能在乾淨、一致的環境中執行。
今天結束後,你將學會:
第一階段:打好基礎(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!
第二個測試會失敗,因為計算器的歷史記錄還保留著第一個測試的資料。
測試隔離是指每個測試案例都應該:
# ✅ 好的測試:每個測試都是獨立的
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
是在每個測試方法執行「之前」都會執行的方法,用來設置測試環境。
建立 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
是在每個測試方法執行「之後」都會執行的方法,用來清理測試環境。
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 機制:
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()
今天我們深入學習了測試生命週期的重要概念:
測試生命週期是確保測試穩定性和可靠性的基礎。通過適當的設置和清理:
記住:良好的測試生命週期管理是可靠測試的基石。
明天我們將學習「參數化測試」,了解如何用同一個測試邏輯驗證多組不同的資料,讓測試更加高效和全面。