iT邦幫忙

2025 iThome 鐵人賽

DAY 23
0
Modern Web

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

Day 23 - 測試篩選與路由 🔀

  • 分享至 

  • xImage
  •  

故事:當待辦事項變成待辦「山」

週一早上,產品經理突然跑來:「客戶反映說找不到重要的待辦事項!他們有 300 多筆資料,全部擠在同一頁...」你打開測試環境一看,密密麻麻的待辦事項像瀑布一樣流下來。沒有分類、沒有篩選、沒有分頁,這不是待辦清單,這是待辦災難!

今天,我們要為 Todo App 加入篩選功能和路由,讓使用者能夠輕鬆管理大量的待辦事項。更重要的是,我們要用 TDD 的方式確保這些功能在各種情況下都能正常運作。

🗺️ 我們的旅程進度

基礎測試 [##########] 100% ✅ (Day 1-10)
Roman Kata [#######] 100% ✅ (Day 11-17)
框架特色 [######----] 60% 🚀 (Day 18-27)
        ↑ 我們在這裡!Day 23

為什麼篩選功能需要測試?

想像一下這些情況:

  • 使用者切換到「已完成」篩選,卻看到未完成的項目
  • URL 分享給同事,對方看到的卻是不同的篩選結果
  • 新增項目後,篩選狀態意外重置

這些都是真實世界中常見的問題。透過 TDD,我們能在開發階段就預防這些問題。

設計篩選功能的測試策略

首先,我們要定義篩選的需求:

  1. 顯示所有待辦事項(All)
  2. 只顯示進行中的項目(Active)
  3. 只顯示已完成的項目(Completed)
  4. 篩選狀態要能透過 URL 分享

建立 tests/day23/TodoFilter.test.tsx

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');
  });
});

實作 TodoFilter 元件

// 建立 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>
  );
}

整合篩選功能到 Todo List

現在讓我們測試篩選功能如何與待辦清單整合:

建立 tests/day23/TodoListWithFilter.test.tsx

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:

建立 tests/day23/TodoRouting.test.tsx

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>
  );
}

測試邊界情況

好的測試要考慮各種邊界情況:

建立 tests/day23/TodoFilterEdgeCases.test.tsx

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 來優化篩選結果的計算。

小挑戰 🎯

試著為你的篩選功能加入這些測試:

  1. 持久化測試:篩選狀態儲存到 localStorage
  2. 鍵盤導航:用方向鍵切換篩選器
  3. 自訂篩選:測試「本週到期」、「高優先級」等自訂篩選
  4. 批量操作:測試「標記所有完成」功能與篩選的互動

本日重點回顧 📝

今天我們學到了:

✅ 如何用 TDD 開發篩選功能
✅ 測試 URL 路由與狀態同步
✅ 處理篩選的邊界情況
✅ 效能優化的測試策略
✅ 使用 useMemo 避免不必要的重新計算

這些測試技巧不只適用於待辦事項,任何需要資料篩選的應用都能使用。記住,好的篩選功能測試要考慮:

  • 資料的各種組合
  • 使用者的操作順序
  • 效能的影響
  • URL 的可分享性

明天預告 🚀

明天(Day 24)我們將探討「測試拖放功能」,學習如何測試複雜的使用者互動!

記住:篩選功能看似簡單,但魔鬼藏在細節裡。透過完整的測試,我們能確保使用者體驗的流暢性!


上一篇
Day 22 - 測試更新與刪除
下一篇
Day 24 - 測試生命週期 Hook 🔄
系列文
React TDD 實戰:用 Vitest 打造可靠的前端應用27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言