跳轉到

Time Compass Capture-First TDD 測試套件深入分析

簡介:Capture-First TDD 的理念

Time Compass 採用 Capture-First TDD(先擷取後驅動)的測試哲學,這是一套突破傳統 Unit Test 與 Mock-Based 測試侷限的創新方法論。

核心問題背景

在開發高度依賴外部 API(Google Calendar、Moodle)的專案時,手動撰寫 Mock 會產生兩大痛點:

  1. Mock 幻覺:開發者自己捏造的 JSON 結構往往與真實 API 回應有落差,導致測試通過但實際整合時掛掉
  2. 開發迴圈極慢:每次程式碼改動都需要發出真實網路請求,導致測試執行時間從秒級跳升到分鐘級

核心決策

強制推行「攔截與快照真相」策略,區分兩層離線資料:

層級 位置 用途 特性
L0 快照 (Snapshots) tests/snapshots/ Unit/Regression 測試 原始、未清洗的 Raw JSON,完全從真實 API 擷取
L1 展示框架 (Fixtures) assets/fixtures/ Dev Mode 示範、前端開發 經清洗、脫敏的資料,供無憑證環境使用

優點

  • 光速回饋迴圈:單元測試套件在數百毫秒內執行完成
  • 🔐 零憑證開發體驗:透過 MCP_DEV_MODE=1 環境變數,評審/設計師無需任何 Token 即可完整驗證流程
  • 消除 Mock 幻覺:確保測試資料與真實 API 結構一致

三層測試架構

Time Compass 實施嚴格的三層分離測試策略,每層有明確的職責邊界與資料來源。

架構圖

┌─────────────────────────────────────────────────────────────┐
│                     UI/MCP Tools (Frontend)                 │
└─────────────────────────────────────────────────────────────┘
                             ↓
┌─────────────────────────────────────────────────────────────┐
│ L3: System Tests (端對端整合,MCP 工具鏈)                   │
│ • Location: tests/system/mcp_suite/                         │
│ • Task: 驗證 MCP 工具註冊,模擬真實用戶對話                │
│ • Data: In-process FastMCP + Snapshot 資料                 │
│ • Speed: ~1-5 秒 per test                                   │
└─────────────────────────────────────────────────────────────┘
                             ↓
┌─────────────────────────────────────────────────────────────┐
│ L2: Integration Tests (領域邏輯與轉換)                      │
│ • Location: tests/unit/integrations/                        │
│ • Task: 驗證 API 響應 → 內部模型 → TOON 序列化            │
│ • Data: tests/snapshots/ 中的 Raw JSON                     │
│ • Speed: ~100ms per test                                    │
└─────────────────────────────────────────────────────────────┘
                             ↓
┌─────────────────────────────────────────────────────────────┐
│ L1: Unit Tests (單一函數/類別的行為)                        │
│ • Location: tests/unit/{services,domain,utils,mcp}/        │
│ • Task: 驗證 Monad、時間邏輯、資料驗證                     │
│ • Data: 直接構建的簡單物件或 Fixture                       │
│ • Speed: ~1-10ms per test                                   │
└─────────────────────────────────────────────────────────────┘
                             ↓
┌─────────────────────────────────────────────────────────────┐
│ L0: Live Tests (真實 API 通訊,擷取快照)                    │
│ • Location: tests/live/                                     │
│ • Task: 與真實 Google/Moodle API 通訊,產生新快照          │
│ • Data Requirement: Google OAuth Token + Moodle 帳密       │
│ • Speed: ~10-60 秒 per test (網路延遲)                     │
│ • Usage: 手動執行(`uv run pytest tests/live/`)           │
│ • Output: tests/snapshots/ 中的 Raw JSON                   │
└─────────────────────────────────────────────────────────────┘

L1 層:單元測試 (Unit Tests)

🏠 位置 : tests/unit/

目錄結構:

tests/unit/
├── domain/
├── integrations/
├── planner/
├── services/
├── mcp/
└── utils/

L2 層:整合測試 (Integration Tests)

🔗 位置 : tests/unit/integrations/ 的專責測試

L3 層:系統測試 (System Tests)

🌐 位置 : tests/system/mcp_suite/

L0 層:Live 測試(真實 API)

🚀 位置 : tests/live/


Fixture 與 Snapshot 管理策略

數據層級的三分法

L0: Raw API Response (最源頭)
  ↓ 轉換
L1: Processed Snapshot (中間格式)
  ↓ 脫敏
L2: Demo Fixtures (展示用)

Snapshot 的生命週期

1️⃣ 擷取階段(Live Capture)

export MOODLE_ACCOUNT="your_account"
export MOODLE_PASSWORD="your_password"
uv run python tests/snapshots/capture_snapshots.py

2️⃣ 配置與管理

# tests/snapshots/lib/snapshot_config.py
SNAPSHOT_START = datetime(2025, 11, 1, 0, 0, 0, tzinfo=timezone.utc)
SNAPSHOT_END = datetime(2025, 11, 30, 23, 59, 59, tzinfo=timezone.utc)

3️⃣ 快照的讀取

# tests/unit/integrations/test_google_calendar.py
import json
from pathlib import Path

def test_parse_calendar_list():
    snapshot_path = Path("tests/snapshots/google/calendar_list.json")
    raw_data = json.loads(snapshot_path.read_text())

    google_calendars = [GoogleCalendarListRead.from_dict(item) for item in raw_data["items"]]

    assert len(google_calendars) > 0

Mock Context 與 Dev Mode 的關係

Dev Mode 架構

啟動方式

export MCP_DEV_MODE=1

核心實現

# src/time_compass/mcp/dev_mode.py

def is_dev_mode() -> bool:
    return os.environ.get("MCP_DEV_MODE") == "1"

async def get_mock_resource_context(view_date=None) -> ResourceContext:
    """模擬完整的 ResourceContext"""
    cal_list_data = load_fixture("google/calendar_list.json")
    events_data = load_fixture("google/calendar/events_list.json")
    # ...
    return ResourceContext(...)

MCP 工具中的 Dev Mode 分支

@mcp.tool()
async def list_tasklists() -> list[dict]:
    """List all Google Tasks"""

    if is_dev_mode():
        fixture = load_fixture("google/tasks/tasklists_list.json")
        return {"items": fixture.get("items", []) if fixture else []}

    # 正常模式
    auth_provider = google_auth_provider()
    result = await async_get_all_tasks(...)
    # ...

常見測試片段與最佳實踐

1. 從 Snapshot 讀取並解析

# ✅ 推薦做法
def test_parse_google_events_from_snapshot():
    snapshot = Path("tests/snapshots/google/events_list.json")
    raw_data = json.loads(snapshot.read_text(encoding="utf-8"))

    events_result = AllCalendarEventsResult.model_validate(raw_data)

    assert len(events_result.items) > 0
    assert all(hasattr(e, "id") for e in events_result.items)

2. Mock 非同步 API 調用

# ✅ 推薦做法
@pytest.mark.asyncio
async def test_calendar_fetch_with_result_monad():
    from unittest.mock import AsyncMock, patch
    from time_compass.utils.result import Ok, Err

    mock_client = AsyncMock()
    mock_auth = AsyncMock(return_value="Bearer token")

    with patch("...batch_execute_async") as mock_batch:
        expected_response = [Ok(AllCalendarEventsResult(...))]
        mock_batch.return_value = expected_response

        result = await async_get_all_events(
            client=mock_client,
            request=GoogleAllCalendarRequest(...),
            google_auth_provider=mock_auth
        )

        assert is_ok(result)

3. 驗證異常與 Error Handling

# ✅ 推薦做法
@pytest.mark.asyncio
async def test_google_api_error_handling():
    from time_compass.integrations.common.exceptions import GoogleAuthError

    # 模擬 API 錯誤
    with patch("...batch_execute_async") as mock_batch:
        mock_batch.side_effect = GoogleAuthError("401 Unauthorized")

        result = await async_get_all_events(...)

        assert is_err(result)
        assert isinstance(result.error, GoogleAuthError)

4. 禁止事項 ❌

# ❌ 禁止:手動捏造大型 Mock Dictionary
def test_calendar_list_bad():
    mock_data = {
        "items": [
            {
                "id": "primary",
                # ... 100 行手捏 JSON
            }
        ]
    }

# ❌ 禁止:在測試中使用 print()
def test_planner_output():
    result = planner.generate()
    print(result)  # ❌ 污染 pytest 輸出

CI/CD 整合與覆蓋率目標

本地執行測試

# 快速迴路(L1 層,< 3 秒)
uv run pytest tests/unit/ -v --tb=short

# 含覆蓋率報告
uv run pytest tests/unit/ --cov=src/time_compass --cov-report=html

# 僅運行特定測試
uv run pytest tests/unit/domain/test_interaction_context.py::test_interaction_context_validation -v

# 錯誤時停止
uv run pytest tests/unit/ -x

# 顯示最慢的 10 個測試
uv run pytest tests/unit/ --durations=10

覆蓋率目標

層級 目標 說明
Domain 層 ≥ 90% 業務邏輯必須充分覆蓋
Integration 層 ≥ 85% API 轉換邏輯需覆蓋多種情境
Utilities 層 ≥ 95% 工具函式應接近 100%
MCP Tools 層 ≥ 80% 涵蓋主要工具與 Dev Mode 分支
整體 ≥ 85% 全專案目標

新增測試的步驟檢查表

📋 Pre-Implementation:問題復現與快照擷取

□ 1. 識別問題
□ 2. 撰寫 Live Test
□ 3. 保存 Snapshot

🔴 Red Phase:撰寫失敗測試

□ 1. 在 tests/unit/ 建立測試類別
□ 2. 從 Snapshot 讀取測試資料
□ 3. 編寫驗證邏輯,執行測試確認失敗

🟢 Green Phase:修正程式碼

□ 1. 定位 Bug 所在
□ 2. 修改程式碼
□ 3. 執行測試確認通過

🟡 Refactor Phase:提升程式碼品質

□ 1. 檢視修正的程式碼
□ 2. 擴充測試(邊際案例、負面測試)
□ 3. 清理臨時檔案

總結與推薦實踐

三層測試的協調運作

日常開發迴路:
  1. 修改程式碼 → 2. uv run pytest tests/unit/ -x → 3. 檢查證據 → 4. 重複

新增功能或修復 Bug:
  1. 執行 Live Test → 2. 提交快照 → 3. 撰寫 Unit Test → 4. 修正程式碼 → 5. 驗證

文檔版本:Phase 4 (Capture-First TDD 完整階段)
適用範圍:tests/ & src/time_compass/