「資料已經建立了,但客戶說要改...」這是每個開發者的日常。今天我們要完善 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 循環,讓資料的生命週期更加完整。
在開始之前,先思考更新與刪除的測試案例:
讓我們從更新功能開始:
建立 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
});
});
});
現在來實作更新功能:
更新 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
});
});
});
現在來實作刪除功能:
建立 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();
});
});
實作刪除功能:
更新 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 循環:
試著實作以下功能:
提示:可以參考今天的實作方式!
恭喜你完成了完整的 Todo App!我們已經實作了:
明天我們將學習如何測試錯誤處理與邊界情況,讓應用程式更加健壯!
「每一個成功的刪除,都是為了更好的開始」- 測試工程師的哲學