iT邦幫忙

2025 iThome 鐵人賽

DAY 20
0
Modern Web

React TDD 實戰:用 Vitest 打造可靠的前端應用系列 第 20

Day 20 - 測試 TodoList 元件 📝

  • 分享至 

  • xImage
  •  

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

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

🗺️ 我們的 TDD 旅程

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

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

📋 TodoList 功能規格

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

💡 小挑戰時間

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

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

提示:記得先寫測試!

🎯 今日重點回顧

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

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


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


上一篇
Day 19 - MSW 設置與基礎 🎭
系列文
React TDD 實戰:用 Vitest 打造可靠的前端應用20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言