iT邦幫忙

2025 iThome 鐵人賽

DAY 20
0

「為什麼待辦事項總是越來越多?」PM 看著滿滿的 backlog 嘆氣。
「因為我們的 TodoList 還沒測試完啊!」我笑著回答。

今天我們要用 TDD 打造一個完整的 TodoList 元件,這不只是一個練習,而是每個開發者都會遇到的實戰場景。

🗺️ 我們的 TDD 旅程

基礎觀念 ✅ → 進階技巧 ✅ → Kata 實戰 ✅ → 【框架應用 📍】 → 下階段 → 最終章

經過前 19 天的訓練,我們已經準備好挑戰真實世界的應用了!

📋 TodoList 功能規格

在開始寫測試前,先定義我們的 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
        }

💡 小挑戰時間

試試看能不能完成這些進階功能:

  1. 優先級排序:讓待辦事項可以設定優先級
  2. 到期日提醒:加入到期日功能
  3. 標籤分類:為待辦事項加上標籤

提示:記得先寫測試!

🎯 今日重點回顧

  • ✅ 用 TDD 完成 TodoList 的核心功能
  • ✅ 實作 CRUD 操作(新增、讀取、更新、刪除)
  • ✅ 加入統計與篩選功能
  • ✅ 透過重構讓程式碼更優雅

明天我們將學習如何為這個 TodoList 加上持久化儲存功能!


小測驗:如果要加入「批次刪除」功能,你會怎麼設計測試案例?


上一篇
Day 19 - 資料庫測試設置 🗄️
系列文
Python pytest TDD 實戰:從零開始的測試驅動開發20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言