iT邦幫忙

2025 iThome 鐵人賽

DAY 22
0

「資料已經建立了,但客戶說要改...」這是每個開發者的日常。今天我們要完善 Todo App 的最後兩個功能:更新與刪除。透過 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: React Testing Library 進階
    ├── Day 19: MSW 測試基礎
    ├── Day 20: 元件狀態測試
    ├── Day 21: 建立與列表測試
    └── Day 22: 測試更新與刪除 📍 我們在這裡!

今天我們要完成 Todo App 的 CRUD 循環,讓資料的生命週期更加完整。

測試策略規劃

在開始之前,先思考更新與刪除的測試案例:

更新 Todo 的測試案例

  • 成功更新 Todo 的標題
  • 切換 Todo 的完成狀態
  • 更新不存在的 Todo 應回傳錯誤
  • 驗證資料確實被更新

刪除 Todo 的測試案例

  • 成功刪除 Todo
  • 刪除不存在的 Todo 應回傳錯誤
  • 驗證資料確實被刪除
  • 刪除後無法再次查詢

更新 Todo - 紅燈階段 🔴

讓我們從更新功能開始:

建立 tests/day22/test_todo_update.tsx

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TodoList } from '../../src/todo/TodoList';

describe('Todo Update Features', () => {
  const mockTodos = [
    { id: '1', title: '準備會議資料', completed: false },
    { id: '2', title: '回覆客戶郵件', completed: false }
  ];

  beforeEach(() => {
    vi.clearAllMocks();
  });

  it('updates todo title', async () => {
    const user = userEvent.setup();
    const onUpdate = vi.fn();
    
    render(
      <TodoList 
        todos={mockTodos}
        onUpdate={onUpdate}
      />
    );

    // 點擊編輯按鈕
    const editButton = screen.getByRole('button', { 
      name: /edit.*準備會議資料/i 
    });
    await user.click(editButton);

    // 應該顯示編輯輸入框
    const input = screen.getByDisplayValue('準備會議資料');
    expect(input).toBeInTheDocument();

    // 修改標題
    await user.clear(input);
    await user.type(input, '準備下週會議資料');
    await user.keyboard('{Enter}');

    // 驗證更新函數被呼叫
    expect(onUpdate).toHaveBeenCalledWith({
      id: '1',
      title: '準備下週會議資料',
      completed: false
    });
  });
});

更新 Todo - 綠燈階段 🟢

現在來實作更新功能:

更新 src/todo/TodoList.tsx

interface TodoListProps {
  todos: Todo[];
  onUpdate?: (todo: Todo) => void;
  onDelete?: (id: string) => void;
}

export function TodoList({ todos, onUpdate, onDelete }: TodoListProps) {
  const [editingId, setEditingId] = useState<string | null>(null);
  const [editValue, setEditValue] = useState('');

  const handleUpdate = (todo: Todo) => {
    if (editValue.trim() && onUpdate) {
      onUpdate({ ...todo, title: editValue.trim() });
      setEditingId(null);
    }
  };

  // 更多實作細節...
}

測試切換完成狀態

建立 tests/day22/test_partial_update.tsx

describe('Partial Updates', () => {
  it('toggles todo completed status', async () => {
    const user = userEvent.setup();
    const onUpdate = vi.fn();
    
    render(
      <TodoList 
        todos={mockTodos}
        onUpdate={onUpdate}
      />
    );

    // 點擊完成核取方塊
    const checkbox = screen.getByRole('checkbox', { 
      name: /toggle.*準備會議資料/i 
    });
    await user.click(checkbox);

    // 驗證更新函數被呼叫
    expect(onUpdate).toHaveBeenCalledWith({
      id: '1',
      title: '準備會議資料',
      completed: true
    });
  });
});

刪除 Todo - 紅燈階段

現在來實作刪除功能:

建立 tests/day22/test_todo_delete.tsx

describe('Todo Delete Features', () => {
  const mockTodos = [
    { id: '1', title: '即將被刪除', completed: false },
    { id: '2', title: '保留項目', completed: false }
  ];

  it('deletes todo with confirmation', async () => {
    const user = userEvent.setup();
    const onDelete = vi.fn();
    
    render(
      <TodoList 
        todos={mockTodos}
        onDelete={onDelete}
      />
    );

    // 點擊刪除按鈕
    const deleteButton = screen.getByRole('button', { 
      name: /delete.*即將被刪除/i 
    });
    await user.click(deleteButton);

    // 應該顯示確認對話框
    expect(screen.getByText(/確定要刪除/)).toBeInTheDocument();
    expect(screen.getByRole('button', { name: '確定' })).toBeInTheDocument();
    expect(screen.getByRole('button', { name: '取消' })).toBeInTheDocument();

    await user.click(screen.getByRole('button', { name: '確定' }));

    expect(onDelete).toHaveBeenCalledWith('1');
  });

  it('cancels delete on cancel button', async () => {
    const user = userEvent.setup();
    const onDelete = vi.fn();
    render(<TodoList todos={mockTodos} onDelete={onDelete} />);

    await user.click(screen.getByRole('button', { name: /delete.*即將被刪除/i }));
    await user.click(screen.getByRole('button', { name: '取消' }));

    expect(onDelete).not.toHaveBeenCalled();
    expect(screen.getByText('即將被刪除')).toBeInTheDocument();
  });
});

刪除 Todo - 綠燈階段

實作刪除功能:

更新 src/todo/TodoList.tsx

const handleDelete = async (id: string) => {
  const confirmed = window.confirm('確定要刪除這個項目嗎?');
  if (confirmed && onDelete) {
    onDelete(id);
  }
};

測試錯誤情境

我們也需要處理更新不存在的 Todo:

建立 tests/day22/test_edge_cases.tsx

describe('Edge Cases', () => {
  it('handles update errors gracefully', async () => {
    const user = userEvent.setup();
    const onUpdate = vi.fn().mockRejectedValue(new Error('更新失敗'));
    
    render(
      <TodoList 
        todos={mockTodos}
        onUpdate={onUpdate}
      />
    );

    await user.click(screen.getByRole('button', { name: /edit/i }));
    const input = screen.getByDisplayValue(mockTodos[0].title);
    await user.clear(input);
    await user.type(input, '新標題');
    await user.keyboard('{Enter}');

    await waitFor(() => {
      expect(screen.getByText('更新失敗')).toBeInTheDocument();
    });
  });

  it('handles delete errors gracefully', async () => {
    const user = userEvent.setup();
    const onDelete = vi.fn().mockRejectedValue(new Error('刪除失敗'));
    
    render(
      <TodoList 
        todos={mockTodos}
        onDelete={onDelete}
      />
    );

    await user.click(screen.getByRole('button', { name: /delete/i }));
    await user.click(screen.getByRole('button', { name: '確定' }));

    await waitFor(() => {
      expect(screen.getByText('刪除失敗')).toBeInTheDocument();
    });
  });
});

測試完整生命週期

讓我們測試一個完整的生命週期:

建立 tests/day22/test_todo_lifecycle.tsx

describe('Todo Lifecycle', () => {
  it('completes full CRUD cycle', async () => {
    const user = userEvent.setup();
    const todos: Todo[] = [];
    const { rerender } = render(
      <TodoList 
        todos={todos}
        onAdd={(title) => todos.push({ id: '1', title, completed: false })}
        onUpdate={(todo) => Object.assign(todos[0], todo)}
        onDelete={(id) => todos.splice(0, 1)}
      />
    );

    // 建立
    const input = screen.getByRole('textbox');
    await user.type(input, 'Lifecycle test');
    await user.keyboard('{Enter}');
    rerender(<TodoList todos={[...todos]} />);
    
    // 更新
    await user.click(screen.getByRole('button', { name: /edit/i }));
    const editInput = screen.getByDisplayValue('Lifecycle test');
    await user.clear(editInput);
    await user.type(editInput, 'Updated test');
    await user.keyboard('{Enter}');
    rerender(<TodoList todos={[...todos]} />);
    
    // 刪除
    await user.click(screen.getByRole('button', { name: /delete/i }));
    await user.click(screen.getByRole('button', { name: '確定' }));
    rerender(<TodoList todos={[...todos]} />);
    
    // 驗證刪除結果
    expect(screen.queryByText('Updated test')).not.toBeInTheDocument();
  });
});

本日學習重點 📝

今天我們完成了 Todo App 的 CRUD 循環:

完成的功能

  1. 更新 Todo:支援編輯標題與狀態切換
  2. 刪除 Todo:包含確認對話框保護
  3. 完整生命週期:從建立到刪除的完整測試

測試技巧

  • 使用 userEvent 模擬使用者互動
  • 測試完整的資料生命週期
  • 處理錯誤情境
  • 確認對話框的測試方式

React 特色

  • 元件狀態管理 (useState)
  • 事件處理與回呼函數
  • 條件式渲染編輯模式
  • 錯誤邊界處理

小挑戰 🏆

試著實作以下功能:

  1. 批次操作:同時選取多個 Todo 進行更新或刪除
  2. 行內編輯:直接在列表中編輯標題
  3. 拖曳排序:透過拖曳改變 Todo 順序

提示:可以參考今天的實作方式!

總結

恭喜你完成了完整的 Todo App!我們已經實作了:

  • Create(建立)- Day 20
  • Read(讀取)- Day 21
  • Update(更新)- Day 22
  • Delete(刪除)- Day 22

明天我們將學習如何測試錯誤處理與邊界情況,讓應用程式更加健壯!


「每一個成功的刪除,都是為了更好的開始」- 測試工程師的哲學


上一篇
Day 21 - 測試新增 Todo ➕
下一篇
Day 23 - 測試篩選與路由 🔀
系列文
React TDD 實戰:用 Vitest 打造可靠的前端應用27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言