iT邦幫忙

2025 iThome 鐵人賽

DAY 23
0

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

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

今天,我們要為 Todo API 加入篩選功能和路由,讓使用者能夠輕鬆管理大量的待辦事項。更重要的是,我們要用 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/Feature/Day23/TodoFilterTest.php

<?php

use App\Models\Todo;
use App\Models\User;

beforeEach(function () {
    $this->user = User::factory()->create();
});

test('shows all todos by default', function () {
    // 建立測試資料
    Todo::factory()->for($this->user)->create([
        'title' => 'Learn Laravel',
        'completed' => false
    ]);
    
    Todo::factory()->for($this->user)->create([
        'title' => 'Write Tests',
        'completed' => true
    ]);
    
    Todo::factory()->for($this->user)->create([
        'title' => 'Deploy App',
        'completed' => false
    ]);
    
    $response = $this->actingAs($this->user)
        ->getJson('/api/todos');
    
    $response->assertOk()
        ->assertJsonCount(3, 'data');
});

test('filters active todos', function () {
    Todo::factory()->for($this->user)->create([
        'title' => 'Learn Laravel',
        'completed' => false
    ]);
    
    Todo::factory()->for($this->user)->create([
        'title' => 'Write Tests',
        'completed' => true
    ]);
    
    Todo::factory()->for($this->user)->create([
        'title' => 'Deploy App',
        'completed' => false
    ]);
    
    $response = $this->actingAs($this->user)
        ->getJson('/api/todos?filter=active');
    
    $response->assertOk()
        ->assertJsonCount(2, 'data')
        ->assertJsonPath('data.0.title', 'Learn Laravel')
        ->assertJsonPath('data.1.title', 'Deploy App');
});

test('filters completed todos', function () {
    Todo::factory()->for($this->user)->create([
        'title' => 'Learn Laravel',
        'completed' => false
    ]);
    
    Todo::factory()->for($this->user)->create([
        'title' => 'Write Tests',
        'completed' => true
    ]);
    
    $response = $this->actingAs($this->user)
        ->getJson('/api/todos?filter=completed');
    
    $response->assertOk()
        ->assertJsonCount(1, 'data')
        ->assertJsonPath('data.0.title', 'Write Tests');
});

實作篩選控制器

更新 app/Http/Controllers/TodoController.php

<?php

namespace App\Http\Controllers;

use App\Models\Todo;
use Illuminate\Http\Request;

class TodoController extends Controller
{
    public function index(Request $request)
    {
        $query = $request->user()->todos();
        
        // 處理篩選參數
        if ($request->has('filter')) {
            switch ($request->filter) {
                case 'active':
                    $query->where('completed', false);
                    break;
                case 'completed':
                    $query->where('completed', true);
                    break;
                // 'all' 不需要額外條件
            }
        }
        
        return response()->json([
            'data' => $query->get(),
            'count' => [
                'all' => $request->user()->todos()->count(),
                'active' => $request->user()->todos()->where('completed', false)->count(),
                'completed' => $request->user()->todos()->where('completed', true)->count(),
            ]
        ]);
    }
}

整合篩選功能到路由

現在讓我們測試篩選功能如何透過路由與 API 整合:

建立 tests/Feature/Day23/TodoRoutingTest.php

<?php

use App\Models\Todo;
use App\Models\User;
use Illuminate\Support\Facades\Route;

beforeEach(function () {
    $this->user = User::factory()->create();
});

test('loads all todos at root path', function () {
    Todo::factory()->for($this->user)->create([
        'title' => 'Task 1',
        'completed' => false
    ]);
    
    Todo::factory()->for($this->user)->create([
        'title' => 'Task 2', 
        'completed' => true
    ]);
    
    $response = $this->actingAs($this->user)
        ->getJson('/api/todos');
        
    $response->assertOk()
        ->assertJsonCount(2, 'data');
});

test('loads active filter from URL', function () {
    Todo::factory()->for($this->user)->create([
        'title' => 'Active Task',
        'completed' => false
    ]);
    
    Todo::factory()->for($this->user)->create([
        'title' => 'Completed Task',
        'completed' => true
    ]);
    
    $response = $this->actingAs($this->user)
        ->getJson('/api/todos/active');
        
    $response->assertOk()
        ->assertJsonCount(1, 'data')
        ->assertJsonPath('data.0.title', 'Active Task');
});

test('loads completed filter from URL', function () {
    Todo::factory()->for($this->user)->create([
        'title' => 'Active Task',
        'completed' => false
    ]);
    
    Todo::factory()->for($this->user)->create([
        'title' => 'Completed Task',
        'completed' => true
    ]);
    
    $response = $this->actingAs($this->user)
        ->getJson('/api/todos/completed');
        
    $response->assertOk()
        ->assertJsonCount(1, 'data')
        ->assertJsonPath('data.0.title', 'Completed Task');
});

test('returns filter counts in response', function () {
    Todo::factory()->for($this->user)->count(3)->create(['completed' => false]);
    Todo::factory()->for($this->user)->count(2)->create(['completed' => true]);
    
    $response = $this->actingAs($this->user)
        ->getJson('/api/todos');
        
    $response->assertOk()
        ->assertJsonPath('count.all', 5)
        ->assertJsonPath('count.active', 3)
        ->assertJsonPath('count.completed', 2);
});

實作路由配置

更新 routes/api.php

<?php

use App\Http\Controllers\TodoController;
use Illuminate\Support\Facades\Route;

Route::middleware('auth:sanctum')->group(function () {
    Route::get('/todos', [TodoController::class, 'index']);
    Route::get('/todos/active', [TodoController::class, 'active']);
    Route::get('/todos/completed', [TodoController::class, 'completed']);
    Route::post('/todos', [TodoController::class, 'store']);
    Route::put('/todos/{todo}', [TodoController::class, 'update']);
    Route::delete('/todos/{todo}', [TodoController::class, 'destroy']);
});

測試邊界情況

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

建立 tests/Feature/Day23/TodoFilterEdgeCasesTest.php

<?php

use App\Models\Todo;
use App\Models\User;

beforeEach(function () {
    $this->user = User::factory()->create();
});

test('handles empty todo list', function () {
    $response = $this->actingAs($this->user)
        ->getJson('/api/todos');
        
    $response->assertOk()
        ->assertJsonCount(0, 'data')
        ->assertJsonPath('count.all', 0)
        ->assertJsonPath('count.active', 0)
        ->assertJsonPath('count.completed', 0);
});

test('handles all completed todos', function () {
    Todo::factory()->for($this->user)->count(2)->create([
        'completed' => true
    ]);
    
    $response = $this->actingAs($this->user)
        ->getJson('/api/todos/active');
        
    $response->assertOk()
        ->assertJsonCount(0, 'data');
    
    $response = $this->actingAs($this->user)
        ->getJson('/api/todos/completed');
        
    $response->assertOk()
        ->assertJsonCount(2, 'data');
});

test('handles all active todos', function () {
    Todo::factory()->for($this->user)->count(2)->create([
        'completed' => false
    ]);
    
    $response = $this->actingAs($this->user)
        ->getJson('/api/todos/active');
        
    $response->assertOk()
        ->assertJsonCount(2, 'data');
    
    $response = $this->actingAs($this->user)
        ->getJson('/api/todos/completed');
        
    $response->assertOk()
        ->assertJsonCount(0, 'data');
});

效能優化重點 🚀

當待辦事項數量很多時,效能變得很重要。透過查詢優化和快取,我們可以提升 API 回應速度。

建立 tests/Feature/Day23/TodoPerformanceTest.php

<?php

use App\Models\Todo;
use App\Models\User;

test('efficiently counts filtered todos', function () {
    $user = User::factory()->create();
    
    // 建立大量測試資料
    Todo::factory()->for($user)->count(50)->create(['completed' => false]);
    Todo::factory()->for($user)->count(50)->create(['completed' => true]);
    
    $start = microtime(true);
    
    $response = $this->actingAs($user)
        ->getJson('/api/todos');
    
    $duration = microtime(true) - $start;
    
    $response->assertOk();
    expect($duration)->toBeLessThan(0.5); // 應在 500ms 內完成
});

test('uses eager loading for relationships', function () {
    $user = User::factory()->create();
    
    Todo::factory()->for($user)->count(10)->create();
    
    // 監控查詢次數
    DB::enableQueryLog();
    
    $response = $this->actingAs($user)
        ->getJson('/api/todos');
    
    $queryCount = count(DB::getQueryLog());
    DB::disableQueryLog();
    
    // 應該只有少量查詢(避免 N+1 問題)
    expect($queryCount)->toBeLessThan(5);
});

實作查詢優化

更新 app/Http/Controllers/TodoController.php

<?php

namespace App\Http\Controllers;

use App\Models\Todo;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;

class TodoController extends Controller
{
    public function index(Request $request)
    {
        $userId = $request->user()->id;
        $filter = $request->get('filter', 'all');
        
        // 使用快取來減少資料庫查詢
        $cacheKey = "todos.{$userId}.{$filter}";
        
        $data = Cache::remember($cacheKey, 60, function () use ($request, $filter) {
            $query = $request->user()->todos();
            
            switch ($filter) {
                case 'active':
                    $query->where('completed', false);
                    break;
                case 'completed':
                    $query->where('completed', true);
                    break;
            }
            
            return $query->with('tags')->get();
        });
        
        // 計算各狀態數量
        $counts = Cache::remember("todos.counts.{$userId}", 60, function () use ($request) {
            return [
                'all' => $request->user()->todos()->count(),
                'active' => $request->user()->todos()->where('completed', false)->count(),
                'completed' => $request->user()->todos()->where('completed', true)->count(),
            ];
        });
        
        return response()->json([
            'data' => $data,
            'count' => $counts
        ]);
    }
    
    public function active(Request $request)
    {
        $request->merge(['filter' => 'active']);
        return $this->index($request);
    }
    
    public function completed(Request $request)
    {
        $request->merge(['filter' => 'completed']);
        return $this->index($request);
    }
}

小挑戰 🎯

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

  1. 持久化測試:篩選狀態儲存到 session 或 cookie
  2. 搜尋結合篩選:測試搜尋功能與篩選的組合
  3. 排序與篩選:測試排序功能與篩選的互動
  4. 分頁與篩選:測試分頁在不同篩選狀態下的行為

本日重點回顧 📝

今天我們學到了:

✅ 如何用 TDD 開發 API 篩選功能
✅ 測試 URL 路由與篩選狀態
✅ 處理篩選的邊界情況
✅ 效能優化的測試策略
✅ 使用快取提升 API 效能

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

  • 資料的各種組合
  • 使用者的操作順序
  • 效能的影響
  • API 的回應格式

明天預告 🚀

明天(Day 24)我們將探討「測試檔案上傳功能」,學習如何處理檔案上傳的各種情境!

記住:篩選功能看似簡單,但魔鬼藏在細節裡。透過完整的測試,我們能確保 API 的穩定性和效能!


上一篇
Day 22 - 測試更新與刪除 🔄
下一篇
Day 24 - 測試生命週期 Hook 🔄
系列文
Laravel Pest TDD 實戰:從零開始的測試驅動開發27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言