「資料已經建立了,但客戶說要改...」這是每個開發者的日常。今天我們要完善 Todo API 的最後兩個功能:更新與刪除。透過 TDD 的方式,確保這些關鍵操作都能正確執行!
基礎測試概念 (Days 1-10)
├── Day 1: 什麼是 TDD?
├── Day 2: 認識斷言
├── Day 3: 紅綠重構循環
├── Day 4: 單元測試基礎
├── Day 5: 測試生命週期
├── Day 6: AAA 模式
├── Day 7: 測試替身基礎
├── Day 8: 參數化測試
├── Day 9: 測試覆蓋率
└── Day 10: 基礎回顧與小結
Roman Numeral Kata (Days 11-17)
├── Day 11: Kata 介紹與起步
├── Day 12: 建立測試結構
├── Day 13: 基礎轉換(I, V, X)
├── Day 14: 進階數字轉換
├── Day 15: 反向轉換
├── Day 16: 錯誤處理
└── Day 17: 重構與最佳化
框架特色開發 (Days 18-27)
├── Day 18: pytest 測試框架進階
├── Day 19: FastAPI 測試基礎
├── Day 20: 資料驅動測試
├── Day 21: 建立與讀取測試
└── Day 22: 測試更新與刪除 📍 我們在這裡!
今天我們要完成 Todo API 的 CRUD 循環,讓資料的生命週期更加完整。
在開始之前,先思考更新與刪除的測試案例:
讓我們從更新功能開始:
建立 tests/day22/test_todo_update.py
import pytest
from fastapi.testclient import TestClient
from src.todo.app import app
from src.todo.models import TodoInDB, TodoUpdate
from src.todo.database import get_db, clear_db
client = TestClient(app)
def setup_function():
"""每個測試前清空資料庫"""
clear_db()
def test_update_todo():
# Arrange: 先建立一個 todo
create_response = client.post(
"/todos/",
json={"title": "原始標題", "completed": False}
)
todo_id = create_response.json()["id"]
# Act: 更新 todo
update_response = client.put(
f"/todos/{todo_id}",
json={"title": "更新後的標題", "completed": True}
)
# Assert
assert update_response.status_code == 200
updated_todo = update_response.json()
assert updated_todo["title"] == "更新後的標題"
assert updated_todo["completed"] is True
assert updated_todo["id"] == todo_id
現在來實作更新功能:
更新 src/todo/app.py
from fastapi import FastAPI, HTTPException
from typing import List, Optional
from .models import TodoCreate, TodoInDB, TodoUpdate
from .database import get_db
app = FastAPI()
@app.put("/todos/{todo_id}", response_model=TodoInDB)
def update_todo(todo_id: str, todo_update: TodoUpdate):
db = get_db()
# 找出要更新的 todo
todo_index = None
for i, todo in enumerate(db.todos):
if todo.id == todo_id:
todo_index = i
break
if todo_index is None:
raise HTTPException(status_code=404, detail="Todo not found")
# 更新資料
existing_todo = db.todos[todo_index]
update_data = todo_update.dict(exclude_unset=True)
updated_todo = existing_todo.copy(update=update_data)
db.todos[todo_index] = updated_todo
return updated_todo
建立 tests/day22/test_partial_update.py
def test_partial_update_title_only():
# Arrange
create_response = client.post(
"/todos/",
json={"title": "原始標題", "completed": False}
)
todo_id = create_response.json()["id"]
# Act: 只更新標題
update_response = client.patch(
f"/todos/{todo_id}",
json={"title": "只更新標題"}
)
# Assert
assert update_response.status_code == 200
updated_todo = update_response.json()
assert updated_todo["title"] == "只更新標題"
assert updated_todo["completed"] is False # 應該保持不變
def test_partial_update_completed_only():
# Arrange
create_response = client.post(
"/todos/",
json={"title": "保持標題", "completed": False}
)
todo_id = create_response.json()["id"]
# Act: 只更新完成狀態
update_response = client.patch(
f"/todos/{todo_id}",
json={"completed": True}
)
# Assert
assert update_response.status_code == 200
updated_todo = update_response.json()
assert updated_todo["title"] == "保持標題" # 應該保持不變
assert updated_todo["completed"] is True
現在來實作刪除功能:
建立 tests/day22/test_todo_delete.py
def test_delete_todo():
# Arrange
create_response = client.post(
"/todos/",
json={"title": "即將被刪除", "completed": False}
)
todo_id = create_response.json()["id"]
# Act
delete_response = client.delete(f"/todos/{todo_id}")
# Assert
assert delete_response.status_code == 204
# 驗證已經被刪除
get_response = client.get(f"/todos/{todo_id}")
assert get_response.status_code == 404
def test_delete_nonexistent_todo():
# Act & Assert
response = client.delete("/todos/nonexistent-id")
assert response.status_code == 404
實作刪除功能:
更新 src/todo/app.py
from fastapi import FastAPI, HTTPException, status
from fastapi.responses import Response
@app.delete("/todos/{todo_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_todo(todo_id: str):
db = get_db()
# 找出要刪除的 todo
todo_index = None
for i, todo in enumerate(db.todos):
if todo.id == todo_id:
todo_index = i
break
if todo_index is None:
raise HTTPException(status_code=404, detail="Todo not found")
# 刪除 todo
db.todos.pop(todo_index)
return Response(status_code=status.HTTP_204_NO_CONTENT)
我們也需要處理更新不存在的 Todo:
建立 tests/day22/test_edge_cases.py
def test_update_nonexistent_todo():
# Act & Assert
response = client.put(
"/todos/nonexistent-id",
json={"title": "不會成功", "completed": True}
)
assert response.status_code == 404
assert response.json()["detail"] == "Todo not found"
def test_update_with_invalid_data():
# Arrange
create_response = client.post(
"/todos/",
json={"title": "測試項目", "completed": False}
)
todo_id = create_response.json()["id"]
# Act & Assert: 測試空標題
response = client.put(
f"/todos/{todo_id}",
json={"title": "", "completed": True}
)
assert response.status_code == 422
今天我們完成了 Todo API 的 CRUD 循環:
試著實作以下功能:
提示:可以參考今天的實作方式!
恭喜你完成了完整的 Todo API!我們已經實作了:
明天我們將學習如何測試 API 的錯誤處理與驗證規則,讓 API 更加健壯!
「每一個成功的刪除,都是為了更好的開始」- 測試工程師的哲學