「為什麼待辦事項總是越來越多?」PM 看著滿滿的 backlog 嘆氣。
「因為我們的 TodoList 還沒測試完啊!」我笑著回答。
今天我們要用 TDD 打造一個完整的 TodoList 元件,這不只是一個練習,而是每個開發者都會遇到的實戰場景。
基礎觀念 ✅ → 進階技巧 ✅ → Kata 實戰 ✅ → 【框架應用 📍】 → 下階段 → 最終章
經過前 19 天的訓練,我們已經準備好挑戰真實世界的應用了!
在開始寫測試前,先定義我們的 TodoList 需要什麼功能:
功能 | 描述 | 優先級 |
---|---|---|
新增項目 | 輸入文字後按 Enter 新增 | 🔴 高 |
顯示列表 | 顯示所有待辦事項 | 🔴 高 |
標記完成 | 點擊勾選框標記完成 | 🟡 中 |
刪除項目 | 點擊刪除按鈕移除 | 🟡 中 |
編輯項目 | 雙擊文字進入編輯模式 | 🟢 低 |
讓我們從最核心的功能開始:
建立 tests/day20/test_todolist.py
import pytest
from src.todo.todolist import TodoList
def test_add_todo_item():
# Arrange
todolist = TodoList()
# Act
todolist.add("買牛奶")
# Assert
assert len(todolist.get_all()) == 1
assert todolist.get_all()[0]["text"] == "買牛奶"
執行測試,紅燈!這正是 TDD 的第一步。
建立 src/todo/todolist.py
from typing import List, Dict, Any
from datetime import datetime
import uuid
class TodoList:
def __init__(self):
self._items: List[Dict[str, Any]] = []
def add(self, text: str) -> Dict[str, Any]:
"""新增待辦事項"""
if not text or not text.strip():
raise ValueError("待辦事項不能為空")
item = {
"id": str(uuid.uuid4()),
"text": text.strip(),
"completed": False,
"created_at": datetime.now().isoformat()
}
self._items.append(item)
return item
def get_all(self) -> List[Dict[str, Any]]:
"""取得所有待辦事項"""
return self._items.copy()
測試通過!綠燈亮起 ✅
測試空值與多筆資料
def test_add_empty_todo_raises_error():
todolist = TodoList()
with pytest.raises(ValueError, match="待辦事項不能為空"):
todolist.add("")
def test_add_multiple_todos():
todolist = TodoList()
items = ["寫測試", "重構程式碼", "喝咖啡"]
for item in items:
todolist.add(item)
assert len(todolist.get_all()) == 3
新增標記完成測試
def test_mark_todo_as_completed():
todolist = TodoList()
todo = todolist.add("完成 Day 20 文章")
todolist.mark_completed(todo["id"])
updated_todo = todolist.get_by_id(todo["id"])
assert updated_todo["completed"] is True
新增刪除測試
def test_delete_todo():
# Arrange
todolist = TodoList()
todo1 = todolist.add("要刪除的項目")
todo2 = todolist.add("要保留的項目")
# Act
todolist.delete(todo1["id"])
# Assert
all_todos = todolist.get_all()
assert len(all_todos) == 1
assert all_todos[0]["id"] == todo2["id"]
新增編輯測試
def test_edit_todo_text():
# Arrange
todolist = TodoList()
todo = todolist.add("原始文字")
# Act
todolist.edit(todo["id"], "修改後的文字")
# Assert
updated_todo = todolist.get_by_id(todo["id"])
assert updated_todo["text"] == "修改後的文字"
新增統計測試
def test_get_statistics():
# Arrange
todolist = TodoList()
for i in range(3):
todolist.add(f"項目 {i+1}")
todolist.mark_completed(todolist.get_all()[0]["id"])
# Act
stats = todolist.get_stats()
# Assert
assert stats["total"] == 3
assert stats["completed"] == 1
assert stats["pending"] == 2
assert stats["completion_rate"] == pytest.approx(33.33, rel=0.01)
實作統計功能
class TodoList:
# ... 前面的程式碼 ...
def get_stats(self) -> Dict[str, Any]:
"""取得統計資訊"""
total = len(self._items)
completed = len([i for i in self._items if i["completed"]])
return {
"total": total,
"completed": completed,
"pending": total - completed,
"completion_rate": (completed / total * 100) if total > 0 else 0
}
完整實作 src/todo/todolist.py
from typing import List, Dict, Any, Optional
from datetime import datetime
import uuid
class TodoList:
"""待辦事項清單管理器"""
def __init__(self):
self._items: List[Dict[str, Any]] = []
def add(self, text: str) -> Dict[str, Any]:
"""新增待辦事項"""
if not text or not text.strip():
raise ValueError("待辦事項不能為空")
item = {
"id": str(uuid.uuid4()),
"text": text.strip(),
"completed": False,
"created_at": datetime.now().isoformat()
}
self._items.append(item)
return item
def get_all(self) -> List[Dict[str, Any]]:
"""取得所有待辦事項"""
return self._items.copy()
def get_by_id(self, item_id: str) -> Optional[Dict[str, Any]]:
"""根據 ID 取得待辦事項"""
return next((item for item in self._items if item["id"] == item_id), None)
def mark_completed(self, item_id: str) -> None:
"""標記待辦事項為完成"""
item = self.get_by_id(item_id)
if not item:
raise ValueError("找不到指定的待辦事項")
item["completed"] = True
def delete(self, item_id: str) -> None:
"""刪除待辦事項"""
for i, item in enumerate(self._items):
if item["id"] == item_id:
del self._items[i]
return
raise ValueError("找不到指定的待辦事項")
def edit(self, item_id: str, new_text: str) -> None:
"""編輯待辦事項文字"""
if not new_text or not new_text.strip():
raise ValueError("待辦事項不能為空")
item = self.get_by_id(item_id)
if not item:
raise ValueError("找不到指定的待辦事項")
item["text"] = new_text.strip()
item["updated_at"] = datetime.now().isoformat()
def get_stats(self) -> Dict[str, Any]:
"""取得統計資訊"""
total = len(self._items)
completed = len([i for i in self._items if i["completed"]])
return {
"total": total,
"completed": completed,
"pending": total - completed,
"completion_rate": (completed / total * 100) if total > 0 else 0
}
試試看能不能完成這些進階功能:
提示:記得先寫測試!
明天我們將學習如何為這個 TodoList 加上持久化儲存功能!
小測驗:如果要加入「批次刪除」功能,你會怎麼設計測試案例?