iT邦幫忙

2025 iThome 鐵人賽

DAY 23
0

故事:當待辦事項變成待辦「山」

週一早上,產品經理突然跑來:「客戶反映說找不到重要的待辦事項!他們有 300 多筆資料,全部擠在同一頁...」你打開測試環境一看,密密麻麻的待辦事項像瀑布一樣流下來。沒有分類、沒有篩選、沒有分頁,這不是待辦清單,這是待辦災難!

今天,我們要為 Todo API 加入篩選功能和路由,讓使用者能夠輕鬆管理大量的待辦事項。更重要的是,我們要用 TDD 的方式確保這些功能在各種情況下都能正常運作。

🗺️ 我們的旅程進度

基礎測試 [##########] 100% ✅ (Day 1-10)
Roman Kata [#######] 100% ✅ (Day 11-17)
框架特色 [######----] 60% 🚀 (Day 18-27)
        ↑ 我們在這裡!Day 23

為什麼篩選功能需要測試?

想像一下這些情況:

  • 使用者切換到「已完成」篩選,卻看到未完成的項目
  • URL 分享給同事,對方看到的卻是不同的篩選結果
  • 新增項目後,篩選狀態意外重置

這些都是真實世界中常見的問題。透過 TDD,我們能在開發階段就預防這些問題。

設計篩選功能的測試策略

首先,我們要定義篩選的需求:

  1. 顯示所有待辦事項(All)
  2. 只顯示進行中的項目(Active)
  3. 只顯示已完成的項目(Completed)
  4. 篩選狀態要能透過 URL 分享

測試篩選功能的實作

# 建立 tests/day23/test_todo_filter.py
import pytest
from fastapi.testclient import TestClient
from src.todo.main import app

client = TestClient(app)

def test_shows_all_todos_by_default():
    """測試預設顯示所有待辦事項"""
    # 建立測試資料
    todos = [
        {"title": "Learn Python", "completed": False},
        {"title": "Write Tests", "completed": True},
        {"title": "Deploy App", "completed": False}
    ]
    
    response = client.get("/api/todos")
    assert response.status_code == 200
    data = response.json()
    assert len(data["todos"]) == 3

def test_filters_active_todos():
    """測試篩選進行中的待辦事項"""
    response = client.get("/api/todos?filter=active")
    assert response.status_code == 200
    data = response.json()
    
    # 確認只有未完成的項目
    for todo in data["todos"]:
        assert todo["completed"] is False

def test_filters_completed_todos():
    """測試篩選已完成的待辦事項"""
    response = client.get("/api/todos?filter=completed")
    assert response.status_code == 200
    data = response.json()
    
    # 確認只有已完成的項目
    for todo in data["todos"]:
        assert todo["completed"] is True

def test_returns_filter_counts():
    """測試返回各篩選狀態的數量"""
    response = client.get("/api/todos")
    assert response.status_code == 200
    data = response.json()
    
    assert "count" in data
    assert "all" in data["count"]
    assert "active" in data["count"]
    assert "completed" in data["count"]

實作篩選 API

# 建立 src/todo/main.py
from fastapi import FastAPI, Query, Depends
from typing import Optional, List
from pydantic import BaseModel

app = FastAPI()

class Todo(BaseModel):
    id: int
    title: str
    completed: bool

class TodoResponse(BaseModel):
    todos: List[Todo]
    count: dict

# 模擬資料庫
todos_db = [
    Todo(id=1, title="Learn Python", completed=False),
    Todo(id=2, title="Write Tests", completed=True),
    Todo(id=3, title="Deploy App", completed=False)
]

@app.get("/api/todos", response_model=TodoResponse)
async def get_todos(
    filter: Optional[str] = Query(None, regex="^(all|active|completed)$")
):
    """獲取待辦事項列表,支援篩選"""
    
    if filter == "active":
        filtered = [t for t in todos_db if not t.completed]
    elif filter == "completed":
        filtered = [t for t in todos_db if t.completed]
    else:
        filtered = todos_db
    
    # 計算各狀態數量
    counts = {
        "all": len(todos_db),
        "active": len([t for t in todos_db if not t.completed]),
        "completed": len([t for t in todos_db if t.completed])
    }
    
    return TodoResponse(todos=filtered, count=counts)

@app.get("/api/todos/active")
async def get_active_todos():
    """獲取進行中的待辦事項"""
    return await get_todos(filter="active")

@app.get("/api/todos/completed")
async def get_completed_todos():
    """獲取已完成的待辦事項"""
    return await get_todos(filter="completed")

整合篩選功能到路由

現在讓我們測試篩選功能如何透過路由與 API 整合:

建立 tests/day23/test_todo_routing.py

import pytest
from fastapi.testclient import TestClient
from src.todo.main import app

client = TestClient(app)

def test_loads_all_todos_at_root_path():
    """測試根路徑載入所有待辦事項"""
    response = client.get("/api/todos")
    assert response.status_code == 200
    data = response.json()
    assert len(data["todos"]) == 3

def test_loads_active_filter_from_url():
    """測試從 URL 載入進行中的篩選"""
    response = client.get("/api/todos/active")
    assert response.status_code == 200
    data = response.json()
    
    # 確認只有未完成的項目
    for todo in data["todos"]:
        assert todo["completed"] is False

def test_loads_completed_filter_from_url():
    """測試從 URL 載入已完成的篩選"""
    response = client.get("/api/todos/completed")
    assert response.status_code == 200
    data = response.json()
    
    # 確認只有已完成的項目
    for todo in data["todos"]:
        assert todo["completed"] is True

def test_preserves_filter_on_refresh():
    """測試重新整理後保持篩選狀態"""
    # 第一次請求
    response1 = client.get("/api/todos/active")
    data1 = response1.json()
    
    # 第二次請求(模擬重新整理)
    response2 = client.get("/api/todos/active")
    data2 = response2.json()
    
    assert data1 == data2

測試邊界情況

好的測試要考慮各種邊界情況:

建立 tests/day23/test_todo_edge_cases.py

import pytest
from fastapi.testclient import TestClient
from src.todo.main import app, todos_db

client = TestClient(app)

def test_handles_empty_todo_list():
    """測試空的待辦清單"""
    # 暫時清空資料
    original = todos_db.copy()
    todos_db.clear()
    
    response = client.get("/api/todos")
    assert response.status_code == 200
    data = response.json()
    
    assert len(data["todos"]) == 0
    assert data["count"]["all"] == 0
    assert data["count"]["active"] == 0
    assert data["count"]["completed"] == 0
    
    # 恢復資料
    todos_db.extend(original)

def test_handles_all_completed_todos():
    """測試所有項目都已完成"""
    # 修改所有項目為已完成
    for todo in todos_db:
        todo.completed = True
    
    response = client.get("/api/todos/active")
    data = response.json()
    assert len(data["todos"]) == 0
    
    response = client.get("/api/todos/completed")
    data = response.json()
    assert len(data["todos"]) == len(todos_db)

def test_handles_all_active_todos():
    """測試所有項目都未完成"""
    # 修改所有項目為未完成
    for todo in todos_db:
        todo.completed = False
    
    response = client.get("/api/todos/active")
    data = response.json()
    assert len(data["todos"]) == len(todos_db)
    
    response = client.get("/api/todos/completed")
    data = response.json()
    assert len(data["todos"]) == 0

效能優化重點 🚀

當待辦事項數量很多時,效能變得很重要。透過查詢優化和快取,我們可以提升 API 回應速度。

建立 tests/day23/test_todo_performance.py

import pytest
import time
from fastapi.testclient import TestClient
from src.todo.main import app, Todo, todos_db

client = TestClient(app)

def test_efficiently_handles_large_datasets():
    """測試處理大量資料的效能"""
    # 建立大量測試資料
    original = todos_db.copy()
    todos_db.clear()
    
    for i in range(100):
        todos_db.append(
            Todo(id=i+1, title=f"Task {i+1}", completed=i % 2 == 0)
        )
    
    start = time.time()
    response = client.get("/api/todos")
    duration = time.time() - start
    
    assert response.status_code == 200
    assert duration < 0.5  # 應在 500ms 內完成
    
    # 恢復原始資料
    todos_db.clear()
    todos_db.extend(original)

def test_filter_performance():
    """測試篩選效能"""
    # 建立大量測試資料
    original = todos_db.copy()
    todos_db.clear()
    
    for i in range(100):
        todos_db.append(
            Todo(id=i+1, title=f"Task {i+1}", completed=i % 2 == 0)
        )
    
    start = time.time()
    
    # 執行多個篩選請求
    client.get("/api/todos?filter=all")
    client.get("/api/todos?filter=active")
    client.get("/api/todos?filter=completed")
    
    duration = time.time() - start
    
    assert duration < 1.0  # 三個請求應在 1 秒內完成
    
    # 恢復原始資料
    todos_db.clear()
    todos_db.extend(original)

實作查詢優化

更新 src/todo/main.py 加入快取機制

from fastapi import FastAPI, Query, Depends
from typing import Optional, List, Dict
from pydantic import BaseModel
from functools import lru_cache
import time

app = FastAPI()

class Todo(BaseModel):
    id: int
    title: str
    completed: bool

# 簡單的記憶體快取
cache: Dict[str, tuple] = {}
CACHE_TTL = 60  # 快取 60 秒

def get_cached_or_compute(key: str, compute_func):
    """獲取快取或計算結果"""
    if key in cache:
        data, timestamp = cache[key]
        if time.time() - timestamp < CACHE_TTL:
            return data
    
    data = compute_func()
    cache[key] = (data, time.time())
    return data

@app.get("/api/todos")
async def get_todos_optimized(
    filter: Optional[str] = Query(None, regex="^(all|active|completed)$")
):
    """優化版的待辦事項 API"""
    cache_key = f"todos_{filter or 'all'}"
    
    def compute():
        if filter == "active":
            filtered = [t for t in todos_db if not t.completed]
        elif filter == "completed":
            filtered = [t for t in todos_db if t.completed]
        else:
            filtered = todos_db
        
        counts = {
            "all": len(todos_db),
            "active": len([t for t in todos_db if not t.completed]),
            "completed": len([t for t in todos_db if t.completed])
        }
        
        return {"todos": filtered, "count": counts}
    
    return get_cached_or_compute(cache_key, compute)

小挑戰 🎯

試著為你的篩選功能加入這些測試:

  1. 持久化測試:篩選狀態儲存到資料庫
  2. 搜尋結合篩選:測試搜尋功能與篩選的組合
  3. 排序與篩選:測試排序功能與篩選的互動
  4. 分頁與篩選:測試分頁在不同篩選狀態下的行為

本日重點回顧 📝

今天我們學到了:

✅ 如何用 TDD 開發 API 篩選功能
✅ 測試 URL 路由與篩選狀態
✅ 處理篩選的邊界情況
✅ 效能優化的測試策略
✅ 使用快取提升 API 效能

這些測試技巧不只適用於待辦事項,任何需要資料篩選的 API 都能使用。記住,好的篩選功能測試要考慮:

  • 資料的各種組合
  • 使用者的操作順序
  • 效能的影響
  • API 的回應格式

明天預告 🚀

明天(Day 24)我們將探討「測試檔案上傳功能」,學習如何處理檔案上傳的各種情境!

記住:篩選功能看似簡單,但魔鬼藏在細節裡。透過完整的測試,我們能確保 API 的穩定性和效能!


上一篇
Day 22 - 測試更新與刪除
下一篇
Day 24 - 測試生命週期 Hook 🔄
系列文
Python pytest TDD 實戰:從零開始的測試驅動開發24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言