「功能看起來很簡單,」PM 指著設計稿說:「使用者輸入待辦事項,按下 Enter 或點擊按鈕就能新增。」你點點頭,心裡卻開始盤算:輸入驗證、狀態更新、UI 回饋、錯誤處理...每個看似簡單的功能背後,都藏著無數的邊界情況。今天,讓我們用 TDD 的方式,一步步實作新增 Todo 的功能!
測試基礎 → 進階觀念 → 框架特性 → 【實戰應用】← 我們在這裡
Day 1-7 Day 8-13 Day 14-20 Day 21-27
經過前二十天的學習,我們已經熟悉了 pytest 框架、FastAPI 測試,以及測試的各種技巧。今天要把這些知識整合起來,實作一個完整的新增 Todo 功能。
在開始寫測試之前,先列出新增 Todo 的需求:
讓我們從最基本的成功案例開始:
# 建立 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
管理測試資料明天我們將學習如何測試更新和刪除 Todo 的功能,讓我們的待辦事項應用更加完整!