iT邦幫忙

2025 iThome 鐵人賽

DAY 26
0

「網站又變慢了!」星期一早上,專案經理衝進辦公室,手上拿著客戶的抱怨信。你打開監控面板,發現 API 回應時間從平均 200ms 飆升到 2 秒。問題是,程式碼明明通過了所有測試,為什麼還會有效能問題?

今天,我們要學習如何用 TDD 的方式來確保程式的效能品質。不只要讓程式「能動」,還要「動得快」!

🗺️ 學習地圖

測試基礎 (Days 1-10) ✅
Kata 修煉 (Days 11-17) ✅
框架實戰 (Days 18-27) 
├── HTTP 測試 ✅
├── 資料庫測試 ✅
├── Mock 進階 ✅
├── 整合測試 ✅
├── API 測試 ✅
├── 測試覆蓋率 ✅
├── CI/CD ✅
└── 效能測試 📍 今天在這!

為什麼需要效能測試?

傳統的功能測試只關心「對不對」,但效能測試關心「快不快」。想像你的 Todo API:

  • ✅ 功能測試:能正確回傳 100 筆待辦事項
  • ⚡ 效能測試:回傳 100 筆待辦事項要在 200ms 內完成

Laravel 效能測試工具箱

1. 基本執行時間測試

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

2. 資料庫查詢效能測試

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

3. 記憶體使用測試

// 更新 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
});

4. 並發測試

測試系統在高並發下的表現:

// 更新 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);
    }
});

實戰:優化 Todo 列表 API

步驟 1:建立效能基準測試

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

步驟 2:實作快取機制

// 更新 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);
    }
}

步驟 3:測試快取效能

// 更新 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);
});

小挑戰

試試看這些進階效能測試:

  1. 測試分頁效能:確保分頁查詢的時間複雜度是 O(1)
  2. 測試搜尋效能:加入全文搜尋索引並測試效能提升
  3. 測試批次操作:測試批次更新 1000 筆資料的效能

重點整理

今天我們學到了:

  1. 效能測試的重要性:不只要正確,還要快速
  2. Laravel 效能測試工具:時間、查詢、記憶體測量
  3. 常見效能問題:N+1 查詢、記憶體洩漏、並發問題
  4. 優化技巧:Eager Loading、快取、查詢優化
  5. 可重用的斷言:建立效能測試輔助函數

效能測試不是奢侈品,而是必需品。當你的應用成長到一定規模,效能問題會變成最大的技術債。透過 TDD 的方式持續監控效能,可以在問題變嚴重之前就發現並解決。

明天是 Day 27,我們將進入測試的最後階段,完成 TDD 旅程的最後一塊拼圖!

下一步挑戰

嘗試為你的專案加入效能測試:

  • 找出最慢的 API endpoint
  • 設定效能基準線
  • 逐步優化並用測試驗證改善

記住:「快」不是偶然,是刻意設計和持續測試的結果!


上一篇
Day 25 - 整合測試 🔗
下一篇
Day 27 - E2E 測試預覽 🎬
系列文
Laravel Pest TDD 實戰:從零開始的測試驅動開發27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言