週一早上,產品經理突然跑來:「客戶反映說找不到重要的待辦事項!他們有 300 多筆資料,全部擠在同一頁...」你打開測試環境一看,密密麻麻的待辦事項像瀑布一樣流下來。沒有分類、沒有篩選、沒有分頁,這不是待辦清單,這是待辦災難!
今天,我們要為 Todo App 加入篩選功能和路由,讓使用者能夠輕鬆管理大量的待辦事項。更重要的是,我們要用 TDD 的方式確保這些功能在各種情況下都能正常運作。
基礎測試 [##########] 100% ✅ (Day 1-10)
Roman Kata [#######] 100% ✅ (Day 11-17)
框架特色 [######----] 60% 🚀 (Day 18-27)
↑ 我們在這裡!Day 23
想像一下這些情況:
這些都是真實世界中常見的問題。透過 TDD,我們能在開發階段就預防這些問題。
首先,我們要定義篩選的需求:
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { TodoFilter } from '../../src/todo/TodoFilter';
describe('TodoFilter', () => {
const mockOnFilterChange = vi.fn();
beforeEach(() => {
mockOnFilterChange.mockClear();
});
it('renders all filter buttons', () => {
render(<TodoFilter activeFilter="all" onFilterChange={mockOnFilterChange} />);
expect(screen.getByText('All')).toBeInTheDocument();
expect(screen.getByText('Active')).toBeInTheDocument();
expect(screen.getByText('Completed')).toBeInTheDocument();
});
it('highlights active filter', () => {
render(<TodoFilter activeFilter="active" onFilterChange={mockOnFilterChange} />);
const activeButton = screen.getByText('Active');
expect(activeButton).toHaveClass('active');
});
it('calls onFilterChange when filter clicked', () => {
render(<TodoFilter activeFilter="all" onFilterChange={mockOnFilterChange} />);
fireEvent.click(screen.getByText('Completed'));
expect(mockOnFilterChange).toHaveBeenCalledWith('completed');
});
});
// 建立 src/todo/TodoFilter.tsx
import React from 'react';
type FilterType = 'all' | 'active' | 'completed';
interface TodoFilterProps {
activeFilter: FilterType;
onFilterChange: (filter: FilterType) => void;
}
export function TodoFilter({ activeFilter, onFilterChange }: TodoFilterProps) {
const filters: FilterType[] = ['all', 'active', 'completed'];
return (
<div className="todo-filter">
{filters.map(filter => (
<button
key={filter}
className={activeFilter === filter ? 'active' : ''}
onClick={() => onFilterChange(filter)}
>
{filter.charAt(0).toUpperCase() + filter.slice(1)}
</button>
))}
</div>
);
}
現在讓我們測試篩選功能如何與待辦清單整合:
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { TodoListWithFilter } from '../../src/todo/TodoListWithFilter';
describe('TodoListWithFilter', () => {
const mockTodos = [
{ id: 1, text: 'Learn React', completed: false },
{ id: 2, text: 'Write Tests', completed: true },
{ id: 3, text: 'Deploy App', completed: false }
];
it('shows all todos by default', () => {
render(<TodoListWithFilter todos={mockTodos} />);
expect(screen.getByText('Learn React')).toBeInTheDocument();
expect(screen.getByText('Write Tests')).toBeInTheDocument();
expect(screen.getByText('Deploy App')).toBeInTheDocument();
});
it('filters active todos', () => {
render(<TodoListWithFilter todos={mockTodos} />);
fireEvent.click(screen.getByText('Active'));
expect(screen.getByText('Learn React')).toBeInTheDocument();
expect(screen.queryByText('Write Tests')).not.toBeInTheDocument();
expect(screen.getByText('Deploy App')).toBeInTheDocument();
});
it('filters completed todos', () => {
render(<TodoListWithFilter todos={mockTodos} />);
fireEvent.click(screen.getByText('Completed'));
expect(screen.queryByText('Learn React')).not.toBeInTheDocument();
expect(screen.getByText('Write Tests')).toBeInTheDocument();
expect(screen.queryByText('Deploy App')).not.toBeInTheDocument();
});
it('shows correct count for each filter', () => {
render(<TodoListWithFilter todos={mockTodos} />);
expect(screen.getByText('All (3)')).toBeInTheDocument();
expect(screen.getByText('Active (2)')).toBeInTheDocument();
expect(screen.getByText('Completed (1)')).toBeInTheDocument();
});
});
// 建立 src/todo/TodoListWithFilter.tsx
import React, { useState, useMemo } from 'react';
import { TodoFilter } from './TodoFilter';
import { TodoItem } from './TodoItem';
interface Todo {
id: number;
text: string;
completed: boolean;
}
interface TodoListWithFilterProps {
todos: Todo[];
}
export function TodoListWithFilter({ todos }: TodoListWithFilterProps) {
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'completed'>('all');
const filteredTodos = useMemo(() => {
switch (activeFilter) {
case 'active':
return todos.filter(todo => !todo.completed);
case 'completed':
return todos.filter(todo => todo.completed);
default:
return todos;
}
}, [todos, activeFilter]);
const counts = useMemo(() => ({
all: todos.length,
active: todos.filter(t => !t.completed).length,
completed: todos.filter(t => t.completed).length
}), [todos]);
return (
<div className="todo-list-container">
<div className="filter-buttons">
{(['all', 'active', 'completed'] as const).map(filter => (
<button
key={filter}
className={activeFilter === filter ? 'active' : ''}
onClick={() => setActiveFilter(filter)}
>
{filter.charAt(0).toUpperCase() + filter.slice(1)} ({counts[filter]})
</button>
))}
</div>
<ul className="todo-list">
{filteredTodos.map(todo => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
</div>
);
}
為了讓篩選狀態能透過 URL 分享,我們需要整合 React Router:
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { TodoApp } from '../../src/todo/TodoApp';
describe('TodoApp Routing', () => {
const renderWithRouter = (initialPath = '/') => {
return render(
<MemoryRouter initialEntries={[initialPath]}>
<Routes>
<Route path="/" element={<TodoApp />} />
<Route path="/active" element={<TodoApp />} />
<Route path="/completed" element={<TodoApp />} />
</Routes>
</MemoryRouter>
);
};
it('loads all todos at root path', () => {
renderWithRouter('/');
const allButton = screen.getByText(/All/);
expect(allButton).toHaveClass('active');
});
it('loads active filter from URL', () => {
renderWithRouter('/active');
const activeButton = screen.getByText(/Active/);
expect(activeButton).toHaveClass('active');
});
it('updates URL when filter changes', () => {
const { container } = renderWithRouter('/');
fireEvent.click(screen.getByText(/Completed/));
// 檢查路由是否更新
expect(window.location.pathname).toBe('/completed');
});
it('preserves filter on page refresh', () => {
// 第一次載入 completed 路由
const { unmount } = renderWithRouter('/completed');
// 模擬頁面重新整理
unmount();
renderWithRouter('/completed');
const completedButton = screen.getByText(/Completed/);
expect(completedButton).toHaveClass('active');
});
});
// 更新 src/todo/TodoApp.tsx
import React, { useState, useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { TodoListWithFilter } from './TodoListWithFilter';
type FilterType = 'all' | 'active' | 'completed';
export function TodoApp() {
const navigate = useNavigate();
const location = useLocation();
const [todos, setTodos] = useState([
{ id: 1, text: 'Learn React', completed: false },
{ id: 2, text: 'Write Tests', completed: true },
{ id: 3, text: 'Deploy App', completed: false }
]);
const getFilterFromPath = (path: string): FilterType => {
const filter = path.slice(1); // 移除開頭的 /
if (filter === 'active' || filter === 'completed') {
return filter;
}
return 'all';
};
const currentFilter = getFilterFromPath(location.pathname);
const handleFilterChange = (filter: FilterType) => {
const path = filter === 'all' ? '/' : `/${filter}`;
navigate(path);
};
const filteredTodos = todos.filter(todo => {
switch (currentFilter) {
case 'active':
return !todo.completed;
case 'completed':
return todo.completed;
default:
return true;
}
});
return (
<div className="todo-app">
<h1>Todo List</h1>
<div className="filter-buttons">
{(['all', 'active', 'completed'] as const).map(filter => (
<button
key={filter}
className={currentFilter === filter ? 'active' : ''}
onClick={() => handleFilterChange(filter)}
>
{filter.charAt(0).toUpperCase() + filter.slice(1)}
</button>
))}
</div>
<ul className="todo-list">
{filteredTodos.map(todo => (
<li key={todo.id} className={todo.completed ? 'completed' : ''}>
{todo.text}
</li>
))}
</ul>
<div className="todo-count">
{filteredTodos.length} {filteredTodos.length === 1 ? 'item' : 'items'}
</div>
</div>
);
}
好的測試要考慮各種邊界情況:
import { describe, it, expect } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { TodoListWithFilter } from '../../src/todo/TodoListWithFilter';
describe('TodoFilter Edge Cases', () => {
it('handles empty todo list', () => {
render(<TodoListWithFilter todos={[]} />);
expect(screen.getByText('All (0)')).toBeInTheDocument();
expect(screen.getByText('Active (0)')).toBeInTheDocument();
expect(screen.getByText('Completed (0)')).toBeInTheDocument();
});
it('handles all completed todos', () => {
const allCompleted = [
{ id: 1, text: 'Task 1', completed: true },
{ id: 2, text: 'Task 2', completed: true }
];
render(<TodoListWithFilter todos={allCompleted} />);
expect(screen.getByText('Active (0)')).toBeInTheDocument();
expect(screen.getByText('Completed (2)')).toBeInTheDocument();
});
it('handles all active todos', () => {
const allActive = [
{ id: 1, text: 'Task 1', completed: false },
{ id: 2, text: 'Task 2', completed: false }
];
render(<TodoListWithFilter todos={allActive} />);
expect(screen.getByText('Active (2)')).toBeInTheDocument();
expect(screen.getByText('Completed (0)')).toBeInTheDocument();
});
});
當待辦事項數量很多時,效能變得很重要。透過 useMemo 和 useCallback,我們可以避免不必要的重新計算和渲染。在前面的實作中,我們已經使用了 useMemo 來優化篩選結果的計算。
試著為你的篩選功能加入這些測試:
今天我們學到了:
✅ 如何用 TDD 開發篩選功能
✅ 測試 URL 路由與狀態同步
✅ 處理篩選的邊界情況
✅ 效能優化的測試策略
✅ 使用 useMemo 避免不必要的重新計算
這些測試技巧不只適用於待辦事項,任何需要資料篩選的應用都能使用。記住,好的篩選功能測試要考慮:
明天(Day 24)我們將探討「測試拖放功能」,學習如何測試複雜的使用者互動!
記住:篩選功能看似簡單,但魔鬼藏在細節裡。透過完整的測試,我們能確保使用者體驗的流暢性!