昨天凌晨三點,手機響了。是值班同事:「購物車結帳功能掛了!」明明單元測試都通過,為什麼還是出問題?因為我們測試了每個零件,卻忘了測試它們組裝起來是否正常運作。這就是整合測試要解決的問題。
前置基礎(Day 1-10)
├── 測試框架設置 ✅
├── 斷言與匹配器 ✅
├── TDD 循環 ✅
└── 測試組織 ✅
Kata 實戰(Day 11-17)
├── Roman Numerals ✅
├── 重構技巧 ✅
└── 進階 TDD ✅
框架特化(Day 18-27)
├── HTTP 測試 ✅
├── 資料庫測試 ✅
├── CRUD 測試 ✅
├── 驗證測試 ✅
├── 中介層測試 ✅
├── API 資源測試 ✅
└── 整合測試 📍  <-- 我們在這裡!
想像你正在組裝一台電腦。每個零件(CPU、記憶體、硬碟)單獨測試都沒問題,但裝在一起卻開不了機。這就是只做單元測試的盲點。
| 特性 | 單元測試 | 整合測試 | 
|---|---|---|
| 範圍 | 單一函數/類別 | 多個元件協作 | 
| 速度 | 快速 ⚡ | 較慢 🐢 | 
| 隔離性 | 完全隔離 | 部分真實環境 | 
| 維護成本 | 低 | 中等 | 
| 信心程度 | 局部信心 | 整體信心 | 
讓我們為 Todo 應用寫一個完整的整合測試,測試從輸入到顯示的完整流程。
<?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']);
});
<?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);
    }
}
<?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();
    }
}
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);
});
<?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);
    }
}
整合測試也要考慮異常情況:
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);
});
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 加入以下整合測試:
提示:整合測試要測試完整的用戶操作流程!
今天我們學習了整合測試的重要概念:
整合測試就像品管的最後一道關卡,確保所有零件組合後能正常運作。記住:好的整合測試能抓到單元測試漏掉的問題!
明天我們將探討「效能測試」,學習如何確保應用的回應速度!
記住:整合測試是信心的來源,它證明你的程式真的能用! 💪