「網站又變慢了!」星期一早上,專案經理衝進辦公室,手上拿著客戶的抱怨信。你打開監控面板,發現 API 回應時間從平均 200ms 飆升到 2 秒。問題是,程式碼明明通過了所有測試,為什麼還會有效能問題?
今天,我們要學習如何用 TDD 的方式來確保程式的效能品質。不只要讓程式「能動」,還要「動得快」!
測試基礎 (Days 1-10) ✅
Kata 修煉 (Days 11-17) ✅
框架實戰 (Days 18-27)
├── HTTP 測試 ✅
├── 資料庫測試 ✅
├── Mock 進階 ✅
├── 整合測試 ✅
├── API 測試 ✅
├── 測試覆蓋率 ✅
├── CI/CD ✅
└── 效能測試 📍 今天在這!
傳統的功能測試只關心「對不對」,但效能測試關心「快不快」。想像你的 Todo API:
// 建立 tests/Feature/Day26/TodoPerformanceTest.php
<?php
namespace Tests\Feature\Day26;
use App\Models\Todo;
use Illuminate\Support\Facades\DB;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('list todos completes within acceptable time', function () {
// Arrange - 建立測試資料
Todo::factory()->count(100)->create();
// Act & Assert
$startTime = microtime(true);
$response = $this->getJson('/api/todos');
$executionTime = (microtime(true) - $startTime) * 1000; // 轉換為毫秒
expect($response->status())->toBe(200);
expect($executionTime)->toBeLessThan(200); // 應在 200ms 內完成
});
N+1 問題是最常見的效能殺手。讓我們用測試來偵測它:
// 更新 tests/Feature/Day26/TodoPerformanceTest.php
test('fetching todos avoids n+1 queries', function () {
// Arrange
$users = \App\Models\User::factory()
->count(10)
->hasTodos(5)
->create();
// Act
DB::enableQueryLog();
$response = $this->getJson('/api/todos');
$queryCount = count(DB::getQueryLog());
DB::disableQueryLog();
// Assert
expect($response->status())->toBe(200);
expect($queryCount)->toBeLessThanOrEqual(3); // 應該只有少數查詢
});
現在執行測試會失敗,因為我們的控制器有 N+1 問題:
// 建立 app/Http/Controllers/TodoController.php (有問題版本)
<?php
namespace App\Http\Controllers;
use App\Models\Todo;
class TodoController extends Controller
{
public function index()
{
$todos = Todo::all();
return response()->json(
$todos->map(function ($todo) {
return [
'id' => $todo->id,
'title' => $todo->title,
'user_name' => $todo->user->name, // N+1 問題!
];
})
);
}
}
修正 N+1 問題:
// 更新 app/Http/Controllers/TodoController.php (修正版本)
<?php
namespace App\Http\Controllers;
use App\Models\Todo;
class TodoController extends Controller
{
public function index()
{
$todos = Todo::with('user')->get(); // 使用 eager loading
return response()->json(
$todos->map(function ($todo) {
return [
'id' => $todo->id,
'title' => $todo->title,
'user_name' => $todo->user->name,
];
})
);
}
}
// 更新 tests/Feature/Day26/TodoPerformanceTest.php
test('batch processing stays within memory limits', function () {
// Arrange
Todo::factory()->count(1000)->create();
// Act
$memoryBefore = memory_get_usage();
$response = $this->getJson('/api/todos/export');
$memoryUsed = memory_get_usage() - $memoryBefore;
$memoryUsedMB = $memoryUsed / 1024 / 1024;
// Assert
expect($response->status())->toBe(200);
expect($memoryUsedMB)->toBeLessThan(50); // 不應超過 50MB
});
測試系統在高並發下的表現:
// 更新 tests/Feature/Day26/TodoPerformanceTest.php
test('handles concurrent requests gracefully', function () {
// Arrange
Todo::factory()->count(50)->create();
$concurrentRequests = 10;
$results = [];
// Act - 同時發送多個請求
$promises = [];
for ($i = 0; $i < $concurrentRequests; $i++) {
$startTime = microtime(true);
$response = $this->getJson('/api/todos');
$results[] = [
'time' => (microtime(true) - $startTime) * 1000,
'status' => $response->status(),
];
}
// Assert
$averageTime = array_sum(array_column($results, 'time')) / count($results);
$maxTime = max(array_column($results, 'time'));
expect($averageTime)->toBeLessThan(300); // 平均時間
expect($maxTime)->toBeLessThan(500); // 最大時間
foreach ($results as $result) {
expect($result['status'])->toBe(200);
}
});
// 建立 tests/Feature/Day26/TodoApiPerformanceTest.php
<?php
namespace Tests\Feature\Day26;
use App\Models\Todo;
use App\Models\User;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('todo list api meets performance requirements', function () {
// Arrange - 建立複雜的測試場景
$users = User::factory()->count(20)->create();
foreach ($users as $user) {
Todo::factory()
->count(rand(5, 15))
->for($user)
->create();
}
// Act
$metrics = measurePerformance(function () {
return $this->getJson('/api/todos');
});
// Assert
expect($metrics['response']->status())->toBe(200);
expect($metrics['time'])->toBeLessThan(200);
expect($metrics['queries'])->toBeLessThanOrEqual(5);
expect($metrics['memory'])->toBeLessThan(10); // MB
});
// 輔助函數
function measurePerformance($callback)
{
DB::enableQueryLog();
$memoryBefore = memory_get_usage();
$startTime = microtime(true);
$response = $callback();
$time = (microtime(true) - $startTime) * 1000;
$queries = count(DB::getQueryLog());
$memory = (memory_get_usage() - $memoryBefore) / 1024 / 1024;
DB::disableQueryLog();
return [
'response' => $response,
'time' => $time,
'queries' => $queries,
'memory' => $memory,
];
}
// 更新 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()
{
// 使用快取減少資料庫查詢
$todos = Cache::remember('todos.all', 60, function () {
return Todo::with(['user'])
->select('id', 'title', 'completed', 'user_id')
->orderBy('created_at', 'desc')
->get()
->map(function ($todo) {
return [
'id' => $todo->id,
'title' => $todo->title,
'completed' => $todo->completed,
'user_name' => $todo->user->name,
];
});
});
return response()->json($todos);
}
public function store(Request $request)
{
$todo = Todo::create($request->validated());
// 清除快取
Cache::forget('todos.all');
return response()->json($todo, 201);
}
}
// 更新 tests/Feature/Day26/TodoApiPerformanceTest.php
test('cache improves subsequent request performance', function () {
// Arrange
Todo::factory()->count(100)->create();
// Act - 第一次請求(快取 miss)
$firstMetrics = measurePerformance(function () {
Cache::flush(); // 清除快取
return $this->getJson('/api/todos');
});
// Act - 第二次請求(快取 hit)
$secondMetrics = measurePerformance(function () {
return $this->getJson('/api/todos');
});
// Assert
expect($firstMetrics['response']->status())->toBe(200);
expect($secondMetrics['response']->status())->toBe(200);
// 第二次應該明顯更快(但可能不會快2倍)
expect($secondMetrics['time'])->toBeLessThan($firstMetrics['time']);
expect($secondMetrics['queries'])->toBeLessThanOrEqual(2); // 快取後查詢更少
});
建立可重用的效能斷言:
// 建立 tests/Helpers/PerformanceAssertions.php
<?php
namespace Tests\Helpers;
use Closure;
use Illuminate\Support\Facades\DB;
trait PerformanceAssertions
{
protected function assertExecutionTime(Closure $callback, int $maxMilliseconds)
{
$startTime = microtime(true);
$result = $callback();
$executionTime = (microtime(true) - $startTime) * 1000;
$this->assertLessThan(
$maxMilliseconds,
$executionTime,
"Execution took {$executionTime}ms, expected less than {$maxMilliseconds}ms"
);
return $result;
}
protected function assertQueryCount(Closure $callback, int $maxQueries)
{
DB::enableQueryLog();
$result = $callback();
$queryCount = count(DB::getQueryLog());
DB::disableQueryLog();
$this->assertLessThanOrEqual(
$maxQueries,
$queryCount,
"Executed {$queryCount} queries, expected at most {$maxQueries}"
);
return $result;
}
protected function assertMemoryUsage(Closure $callback, float $maxMegabytes)
{
$memoryBefore = memory_get_usage();
$result = $callback();
$memoryUsed = (memory_get_usage() - $memoryBefore) / 1024 / 1024;
$this->assertLessThan(
$maxMegabytes,
$memoryUsed,
"Used {$memoryUsed}MB, expected less than {$maxMegabytes}MB"
);
return $result;
}
}
使用輔助函數:
// 更新 tests/Feature/Day26/TodoApiPerformanceTest.php
use Tests\Helpers\PerformanceAssertions;
uses(PerformanceAssertions::class);
test('optimized todo endpoint meets all performance criteria', function () {
// Arrange
Todo::factory()->count(200)->create();
// Act & Assert - 執行時間
$response = $this->assertExecutionTime(function () {
return $this->getJson('/api/todos');
}, 300);
// Assert - 查詢數量
$this->assertQueryCount(function () {
return $this->getJson('/api/todos');
}, 2);
// Assert - 記憶體使用
$this->assertMemoryUsage(function () {
return $this->getJson('/api/todos');
}, 20);
expect($response->status())->toBe(200);
});
試試看這些進階效能測試:
今天我們學到了:
效能測試不是奢侈品,而是必需品。當你的應用成長到一定規模,效能問題會變成最大的技術債。透過 TDD 的方式持續監控效能,可以在問題變嚴重之前就發現並解決。
明天是 Day 27,我們將進入測試的最後階段,完成 TDD 旅程的最後一塊拼圖!
嘗試為你的專案加入效能測試:
記住:「快」不是偶然,是刻意設計和持續測試的結果!