iT邦幫忙

2025 iThome 鐵人賽

DAY 21
0

「功能看起來很簡單,」PM 指著設計稿說:「使用者輸入待辦事項,按下 Enter 或點擊按鈕就能新增。」你點點頭,心裡卻開始盤算:輸入驗證、狀態更新、UI 回饋、錯誤處理...每個看似簡單的功能背後,都藏著無數的邊界情況。今天,讓我們用 TDD 的方式,一步步實作新增 Todo 的功能!

🗺️ 第二十一天的旅程

測試基礎 → 進階觀念 → 框架特性 → 【實戰應用】← 我們在這裡
  Day 1-7    Day 8-13    Day 14-20    Day 21-27

經過前二十天的學習,我們已經熟悉了 pytest 框架、FastAPI 測試,以及測試的各種技巧。今天要把這些知識整合起來,實作一個完整的新增 Todo 功能。

📝 分析需求

在開始寫測試之前,先列出新增 Todo 的需求:

  1. 基本輸入:使用者可以輸入待辦事項
  2. 提交方式:透過 API POST 請求新增
  3. 輸入驗證:不允許空白或只有空格的待辦事項
  4. 狀態管理:成功新增後回傳 201 狀態碼
  5. API 互動:將新待辦事項儲存到資料庫

🔴 紅燈:第一個測試

讓我們從最基本的成功案例開始:

# 建立 tests/day21/test_add_todo.py
import pytest
from fastapi.testclient import TestClient
from datetime import datetime

from src.todo.main import app

client = TestClient(app)


def test_create_todo_successfully():
    """測試成功新增 Todo"""
    # Arrange
    todo_data = {
        "title": "寫測試",
        "completed": False
    }
    
    # Act
    response = client.post("/todos", json=todo_data)
    
    # Assert
    assert response.status_code == 201
    assert response.json()["title"] == "寫測試"
    assert response.json()["completed"] is False
    assert "id" in response.json()
    assert "created_at" in response.json()

執行測試,當然會失敗:

pytest tests/day21/test_add_todo.py -v

# 失敗:404 Not Found

🟢 綠燈:實作新增功能

現在來實作 POST 端點:

# 更新 src/todo/main.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from datetime import datetime
from typing import List, Optional
import uuid

app = FastAPI(title="Todo API")


class TodoCreate(BaseModel):
    """新增 Todo 的請求模型"""
    title: str
    completed: bool = False


class Todo(BaseModel):
    """Todo 回應模型"""
    id: str
    title: str
    completed: bool
    created_at: datetime


# 記憶體儲存(暫時)
todos_db: dict[str, Todo] = {}


@app.get("/")
def read_root():
    return {"message": "Todo API is running"}


@app.get("/todos", response_model=List[Todo])
def get_todos():
    """取得所有 Todo"""
    return list(todos_db.values())


@app.post("/todos", response_model=Todo, status_code=201)
def create_todo(todo: TodoCreate):
    """新增 Todo"""
    new_todo = Todo(
        id=str(uuid.uuid4()),
        title=todo.title,
        completed=todo.completed,
        created_at=datetime.now()
    )
    
    todos_db[new_todo.id] = new_todo
    return new_todo

再次執行測試:

pytest tests/day21/test_add_todo.py -v

# 通過!

🛑️ 測試驗證邏輯

現在加入驗證測試,確保不會接受無效的資料:

# 更新 tests/day21/test_add_todo.py

def test_create_todo_empty_title():
    """測試空標題應該失敗"""
    # Arrange
    todo_data = {
        "title": "",
        "completed": False
    }
    
    # Act
    response = client.post("/todos", json=todo_data)
    
    # Assert
    assert response.status_code == 422


✅ 加強驗證

實作驗證規則:

# 更新 src/todo/main.py
from pydantic import BaseModel, Field, validator

class TodoCreate(BaseModel):
    """新增 Todo 的請求模型"""
    title: str = Field(..., min_length=1, max_length=200)
    completed: bool = False
    
    @validator('title')
    def title_not_empty(cls, v):
        if not v or v.strip() == '':
            raise ValueError('標題不能為空')
        return v.strip()

💾 測試持久性

確保新增的 Todo 真的被儲存了:

# 更新 tests/day21/test_add_todo.py

def test_created_todo_persists():
    """測試新增的 Todo 能被查詢到"""
    # Arrange
    todo_data = {
        "title": "持久化測試",
        "completed": False
    }
    
    # Act - 新增 Todo
    create_response = client.post("/todos", json=todo_data)
    created_id = create_response.json()["id"]
    
    # Act - 查詢所有 Todo
    get_response = client.get("/todos")
    
    # Assert
    todos = get_response.json()
    created_todo = next((t for t in todos if t["id"] == created_id), None)
    
    assert created_todo is not None
    assert created_todo["title"] == "持久化測試"
    assert created_todo["completed"] is False

🧪 測試隔離

為了確保測試之間不會互相影響,我們需要在每個測試前後清理資料:

# 更新 tests/day21/test_add_todo.py

@pytest.fixture(autouse=True)
def clear_todos():
    """每個測試前後清理 todos"""
    from src.todo.main import todos_db
    
    # Setup
    todos_db.clear()
    
    yield  # 執行測試
    
    # Teardown
    todos_db.clear()


💡 重構:完整實作

最後,讓我們看看完整的實作:

# 完整測試 tests/day21/test_add_todo.py
import pytest
from fastapi.testclient import TestClient
from datetime import datetime

from src.todo.main import app, todos_db

client = TestClient(app)


@pytest.fixture(autouse=True)
def clear_todos():
    """每個測試前後清理 todos"""
    todos_db.clear()
    yield
    todos_db.clear()


def test_create_todo_successfully():
    """測試成功新增 Todo"""
    todo_data = {
        "title": "寫測試",
        "completed": False
    }
    
    response = client.post("/todos", json=todo_data)
    
    assert response.status_code == 201
    assert response.json()["title"] == "寫測試"
    assert response.json()["completed"] is False
    assert "id" in response.json()
    assert "created_at" in response.json()


def test_create_todo_empty_title():
    """測試空標題應該失敗"""
    todo_data = {
        "title": "",
        "completed": False
    }
    
    response = client.post("/todos", json=todo_data)
    
    assert response.status_code == 422

🎯 本日重點整理

✅ 測試要點

  • 基本測試:驗證新增功能正常運作
  • 驗證測試:確保輸入驗證正確
  • 錯誤測試:測試各種錯誤情況
  • 整合測試:測試完整流程

🔧 技術重點

  • 使用 TestClient 測試 API
  • @pytest.fixture 管理測試資料
  • 運用 Pydantic 進行資料驗證
  • 實作測試隔離確保測試獨立性

明天我們將學習如何測試更新和刪除 Todo 的功能,讓我們的待辦事項應用更加完整!

📚 參考資源


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

尚未有邦友留言

立即登入留言