週一早上,產品經理突然跑來:「客戶反映說找不到重要的待辦事項!他們有 300 多筆資料,全部擠在同一頁...」你打開測試環境一看,密密麻麻的待辦事項像瀑布一樣流下來。沒有分類、沒有篩選、沒有分頁,這不是待辦清單,這是待辦災難!
今天,我們要為 Todo API 加入篩選功能和路由,讓使用者能夠輕鬆管理大量的待辦事項。更重要的是,我們要用 TDD 的方式確保這些功能在各種情況下都能正常運作。
基礎測試 [##########] 100% ✅ (Day 1-10)
Roman Kata [#######] 100% ✅ (Day 11-17)
框架特色 [######----] 60% 🚀 (Day 18-27)
↑ 我們在這裡!Day 23
想像一下這些情況:
這些都是真實世界中常見的問題。透過 TDD,我們能在開發階段就預防這些問題。
首先,我們要定義篩選的需求:
建立 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 整合:
<?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']);
});
好的測試要考慮各種邊界情況:
<?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 回應速度。
<?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);
}
}
試著為你的篩選功能加入這些測試:
今天我們學到了:
✅ 如何用 TDD 開發 API 篩選功能
✅ 測試 URL 路由與篩選狀態
✅ 處理篩選的邊界情況
✅ 效能優化的測試策略
✅ 使用快取提升 API 效能
這些測試技巧不只適用於待辦事項,任何需要資料篩選的 API 都能使用。記住,好的篩選功能測試要考慮:
明天(Day 24)我們將探討「測試檔案上傳功能」,學習如何處理檔案上傳的各種情境!
記住:篩選功能看似簡單,但魔鬼藏在細節裡。透過完整的測試,我們能確保 API 的穩定性和效能!