「為什麼待辦事項總是越來越多?」PM 看著滿滿的 backlog 嘆氣。
「因為我們的 TodoList 還沒測試完啊!」我笑著回答。
今天我們要用 TDD 打造一個完整的 TodoList 元件,這不只是一個練習,而是每個開發者都會遇到的實戰場景。
基礎觀念 ✅ → 進階技巧 ✅ → Kata 實戰 ✅ → 【框架應用 📍】 → 下階段 → 最終章
經過前 19 天的訓練,我們已經準備好挑戰真實世界的應用了!
在開始寫測試前,先定義我們的 TodoList 需要什麼功能:
功能 | 描述 | 優先級 |
---|---|---|
新增項目 | 輸入文字後按 Enter 新增 | 🔴 高 |
顯示列表 | 顯示所有待辦事項 | 🔴 高 |
標記完成 | 點擊勾選框標記完成 | 🟡 中 |
刪除項目 | 點擊刪除按鈕移除 | 🟡 中 |
編輯項目 | 雙擊文字進入編輯模式 | 🟢 低 |
讓我們從最核心的功能開始:
建立 tests/day20/TodoList.test.tsx
import { render, screen, fireEvent } from '@testing-library/react'
import { TodoList } from '@/components/TodoList'
describe('TodoList', () => {
it('adds_new_todo_item', () => {
render(<TodoList />)
const input = screen.getByPlaceholderText('新增待辦事項')
const button = screen.getByText('新增')
fireEvent.change(input, { target: { value: '買牛奶' } })
fireEvent.click(button)
expect(screen.getByText('買牛奶')).toBeInTheDocument()
})
})
執行測試,紅燈!這正是 TDD 的第一步。
建立 src/components/TodoList.tsx
import { useState } from 'react'
interface Todo {
id: string
text: string
completed: boolean
}
export function TodoList() {
const [todos, setTodos] = useState<Todo[]>([])
const [input, setInput] = useState('')
const addTodo = () => {
if (!input.trim()) return
const newTodo: Todo = {
id: Date.now().toString(),
text: input.trim(),
completed: false
}
setTodos([...todos, newTodo])
setInput('')
}
return (
<div>
<div>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="新增待辦事項"
/>
<button onClick={addTodo}>新增</button>
</div>
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</div>
)
}
測試通過!綠燈亮起 ✅
測試空值與多筆資料
it('prevents_adding_empty_todo', () => {
render(<TodoList />)
const button = screen.getByText('新增')
fireEvent.click(button)
expect(screen.queryByRole('listitem')).not.toBeInTheDocument()
})
it('adds_multiple_todos', () => {
render(<TodoList />)
const input = screen.getByPlaceholderText('新增待辦事項')
const button = screen.getByText('新增')
['寫測試', '重構程式碼', '喝咖啡'].forEach(text => {
fireEvent.change(input, { target: { value: text } })
fireEvent.click(button)
})
expect(screen.getAllByRole('listitem')).toHaveLength(3)
})
新增標記完成測試
it('marks_todo_as_completed', () => {
render(<TodoList />)
// 新增一個待辦事項
const input = screen.getByPlaceholderText('新增待辦事項')
fireEvent.change(input, { target: { value: '完成 Day 20 文章' } })
fireEvent.click(screen.getByText('新增'))
// 點擊勾選框
const checkbox = screen.getByRole('checkbox')
fireEvent.click(checkbox)
expect(checkbox).toBeChecked()
})
新增刪除測試
it('deletes_todo_item', () => {
render(<TodoList />)
// 新增兩個項目
const input = screen.getByPlaceholderText('新增待辦事項')
fireEvent.change(input, { target: { value: '要刪除的項目' } })
fireEvent.click(screen.getByText('新增'))
fireEvent.change(input, { target: { value: '要保留的項目' } })
fireEvent.click(screen.getByText('新增'))
// 刪除第一個項目
const deleteButtons = screen.getAllByText('刪除')
fireEvent.click(deleteButtons[0])
expect(screen.queryByText('要刪除的項目')).not.toBeInTheDocument()
expect(screen.getByText('要保留的項目')).toBeInTheDocument()
})
新增編輯測試
it('edits_todo_text', () => {
render(<TodoList />)
// 新增項目
const input = screen.getByPlaceholderText('新增待辦事項')
fireEvent.change(input, { target: { value: '原始文字' } })
fireEvent.click(screen.getByText('新增'))
// 雙擊進入編輯模式
const todoText = screen.getByText('原始文字')
fireEvent.doubleClick(todoText)
// 編輯文字
const editInput = screen.getByDisplayValue('原始文字')
fireEvent.change(editInput, { target: { value: '修改後的文字' } })
fireEvent.blur(editInput)
expect(screen.getByText('修改後的文字')).toBeInTheDocument()
})
新增統計測試
it('shows_statistics', () => {
render(<TodoList />)
// 新增三個項目
const input = screen.getByPlaceholderText('新增待辦事項')
for (let i = 1; i <= 3; i++) {
fireEvent.change(input, { target: { value: `項目 ${i}` } })
fireEvent.click(screen.getByText('新增'))
}
// 標記第一個為完成
const checkboxes = screen.getAllByRole('checkbox')
fireEvent.click(checkboxes[0])
expect(screen.getByText('總計: 3')).toBeInTheDocument()
expect(screen.getByText('已完成: 1')).toBeInTheDocument()
expect(screen.getByText('待完成: 2')).toBeInTheDocument()
})
完整實作 src/components/TodoList.tsx
import { useState } from 'react'
interface Todo {
id: string
text: string
completed: boolean
}
export function TodoList() {
const [todos, setTodos] = useState<Todo[]>([])
const [input, setInput] = useState('')
const [editingId, setEditingId] = useState<string | null>(null)
const addTodo = () => {
if (!input.trim()) return
setTodos([...todos, {
id: Date.now().toString(),
text: input.trim(),
completed: false
}])
setInput('')
}
const toggleTodo = (id: string) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
))
}
const deleteTodo = (id: string) => {
setTodos(todos.filter(todo => todo.id !== id))
}
const editTodo = (id: string, newText: string) => {
if (!newText.trim()) return
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, text: newText } : todo
))
setEditingId(null)
}
const stats = {
total: todos.length,
completed: todos.filter(t => t.completed).length,
pending: todos.filter(t => !t.completed).length
}
return (
<div>
<input value={input} onChange={(e) => setInput(e.target.value)} placeholder="新增待辦事項" />
<button onClick={addTodo}>新增</button>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input type="checkbox" checked={todo.completed} onChange={() => toggleTodo(todo.id)} />
{editingId === todo.id ? (
<input defaultValue={todo.text} onBlur={(e) => editTodo(todo.id, e.target.value)} autoFocus />
) : (
<span onDoubleClick={() => setEditingId(todo.id)} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
)}
<button onClick={() => deleteTodo(todo.id)}>刪除</button>
</li>
))}
</ul>
{todos.length > 0 && (
<div>
<div>總計: {stats.total}</div>
<div>已完成: {stats.completed}</div>
<div>待完成: {stats.pending}</div>
</div>
)}
</div>
)
}
試試看能不能完成這些進階功能:
提示:記得先寫測試!
明天我們將學習如何為這個 TodoList 加上持久化儲存功能!
小測驗:如果要加入「批次刪除」功能,你會怎麼設計測試案例?