iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0

昨天凌晨三點,手機響了。是值班同事:「購物車結帳功能掛了!」明明單元測試都通過,為什麼還是出問題?因為我們測試了每個零件,卻忘了測試它們組裝起來是否正常運作。這就是整合測試要解決的問題。

本日學習地圖 🗺️

前置基礎(Day 1-10)
├── 測試框架設置 ✅
├── 斷言與匹配器 ✅
├── TDD 循環 ✅
└── 測試組織 ✅

Kata 實戰(Day 11-17)
├── Roman Numerals ✅
├── 重構技巧 ✅
└── 進階 TDD ✅

框架特化(Day 18-27)
├── HTTP 測試 ✅
├── 資料庫測試 ✅
├── CRUD 測試 ✅
├── 驗證測試 ✅
├── 中介層測試 ✅
├── API 資源測試 ✅
└── 整合測試 📍  <-- 我們在這裡!

為什麼需要整合測試? 🤔

想像你正在組裝一台電腦。每個零件(CPU、記憶體、硬碟)單獨測試都沒問題,但裝在一起卻開不了機。這就是只做單元測試的盲點。

單元測試 vs 整合測試

特性 單元測試 整合測試
範圍 單一函數/類別 多個元件協作
速度 快速 ⚡ 較慢 🐢
隔離性 完全隔離 部分真實環境
維護成本 中等
信心程度 局部信心 整體信心

今日實作:Todo App 整合測試 🏗️

讓我們為 Todo 應用寫一個完整的整合測試,測試從輸入到顯示的完整流程。

實作第一個整合測試

建立 tests/Feature/Day25/TodoIntegrationTest.php

<?php

namespace Tests\Feature\Day25;

use App\Models\Todo;
use App\Services\TodoService;
use App\Repositories\TodoRepository;
use Tests\TestCase;

test('create_todo_through_service_and_repository', function () {
    $service = new TodoService(new TodoRepository());
    $data = ['title' => 'Write integration tests', 'completed' => false];
    
    $todo = $service->create($data);
    
    expect($todo)->toBeInstanceOf(Todo::class)
        ->and($todo->title)->toBe('Write integration tests')
        ->and($todo->completed)->toBeFalse();
    
    $this->assertDatabaseHas('todos', ['title' => 'Write integration tests']);
});

建立 app/Repositories/TodoRepository.php

<?php

namespace App\Repositories;

use App\Models\Todo;

class TodoRepository
{
    public function find(int $id): ?Todo
    {
        return Todo::find($id);
    }
    
    public function create(array $data): Todo
    {
        return Todo::create($data);
    }
    
    public function update(Todo $todo, array $data): bool
    {
        return $todo->update($data);
    }
}

建立 app/Services/TodoService.php

<?php

namespace App\Services;

use App\Models\Todo;
use App\Repositories\TodoRepository;

class TodoService
{
    public function __construct(private TodoRepository $repository) {}
    
    public function create(array $data): Todo
    {
        if (empty($data['title'])) {
            throw new \InvalidArgumentException('Title is required');
        }
        
        $data['completed'] = $data['completed'] ?? false;
        return $this->repository->create($data);
    }
    
    public function toggleComplete(int $id): ?Todo
    {
        $todo = $this->repository->find($id);
        
        if (!$todo) {
            return null;
        }
        
        $this->repository->update($todo, [
            'completed' => !$todo->completed
        ]);
        
        return $todo->fresh();
    }
}

測試完整的請求流程 🚀

更新 tests/Feature/Day25/TodoIntegrationTest.php

test('complete_api_flow_with_service_layer', function () {
    $todo = Todo::create([
        'title' => 'Learn integration testing',
        'completed' => false
    ]);
    
    $response = $this->putJson("/api/todos/{$todo->id}/toggle");
    
    $response->assertOk()
        ->assertJsonStructure(['id', 'title', 'completed'])
        ->assertJson(['completed' => true]);
    
    $this->assertDatabaseHas('todos', [
        'id' => $todo->id,
        'completed' => true
    ]);
});

test('handle_validation_error_in_integration', function () {
    $response = $this->postJson('/api/todos', ['title' => '']);
    
    $response->assertStatus(422)
        ->assertJsonValidationErrors(['title']);
    
    $this->assertDatabaseCount('todos', 0);
});

建立 app/Http/Controllers/TodoController.php

<?php

namespace App\Http\Controllers;

use App\Services\TodoService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;

class TodoController extends Controller
{
    public function __construct(private TodoService $service) {}
    
    public function store(Request $request): JsonResponse
    {
        $validated = $request->validate([
            'title' => 'required|string|max:255',
            'completed' => 'boolean'
        ]);
        
        $todo = $this->service->create($validated);
        return response()->json($todo, 201);
    }
    
    public function toggle(int $id): JsonResponse
    {
        $todo = $this->service->toggleComplete($id);
        
        if (!$todo) {
            return response()->json(['message' => 'Todo not found'], 404);
        }
        
        return response()->json($todo);
    }
}

測試錯誤處理 ⚠️

整合測試也要考慮異常情況:

測試資料庫交易

更新 tests/Feature/Day25/TodoIntegrationTest.php

test('rollback_transaction_on_error', function () {
    $initialCount = Todo::count();
    
    try {
        \DB::transaction(function () {
            Todo::create(['title' => 'First todo', 'completed' => false]);
            Todo::create(['title' => 'Second todo', 'completed' => false]);
            throw new \Exception('Something went wrong');
        });
    } catch (\Exception $e) {
        // 預期會捕獲例外
    }
    
    expect(Todo::count())->toBe($initialCount);
});

test('successful_batch_operation', function () {
    $todos = [
        ['title' => 'Task 1', 'completed' => false],
        ['title' => 'Task 2', 'completed' => false],
        ['title' => 'Task 3', 'completed' => false]
    ];
    
    \DB::transaction(function () use ($todos) {
        foreach ($todos as $todo) {
            Todo::create($todo);
        }
    });
    
    expect(Todo::count())->toBe(3);
});

整合測試的層級 📊

完整實作 tests/Feature/Day25/TodoIntegrationTest.php

use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

test('use_real_dependencies', function () {
    $service = app(TodoService::class);
    
    $todo = $service->create([
        'title' => 'Real integration test',
        'completed' => false
    ]);
    
    expect($todo->exists)->toBeTrue();
});

test('handle_concurrent_updates', function () {
    $todo = Todo::create(['title' => 'Concurrent test', 'completed' => false]);
    
    $service = app(TodoService::class);
    $service->toggleComplete($todo->id);
    $service->toggleComplete($todo->id);
    
    $todo->refresh();
    expect($todo->completed)->toBeFalse(); // toggle 兩次
});

test('database_is_cleaned_between_tests', function () {
    Todo::create(['title' => 'Test 1', 'completed' => false]);
    expect(Todo::count())->toBe(1);
});

實戰練習:購物車整合測試 🛒

試著實作一個購物車的整合測試,包含商品加入、數量調整、總價計算等完整流程。

小挑戰 🎯

請為你的 Todo App 加入以下整合測試:

  1. 批次操作測試:選擇多個 todos 並批次刪除
  2. 搜尋功能測試:輸入關鍵字過濾 todos
  3. 拖放排序測試:拖動 todo 項目改變順序

提示:整合測試要測試完整的用戶操作流程!

本日重點回顧 📝

今天我們學習了整合測試的重要概念:

  1. ✅ 理解整合測試的價值與定位
  2. ✅ 實作完整的用戶流程測試
  3. ✅ 處理異步操作與錯誤情況
  4. ✅ 平衡不同層級的測試

整合測試就像品管的最後一道關卡,確保所有零件組合後能正常運作。記住:好的整合測試能抓到單元測試漏掉的問題!

明日預告 🚀

明天我們將探討「效能測試」,學習如何確保應用的回應速度!

記住:整合測試是信心的來源,它證明你的程式真的能用! 💪


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

尚未有邦友留言

立即登入留言