昨天凌晨三點,手機響了。是值班同事:「購物車結帳功能掛了!」明明單元測試都通過,為什麼還是出問題?因為我們測試了每個零件,卻忘了測試它們組裝起來是否正常運作。這就是整合測試要解決的問題。
前置基礎(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 加入以下整合測試:
提示:整合測試要測試完整的用戶操作流程!
今天我們學習了整合測試的重要概念:
整合測試就像品管的最後一道關卡,確保所有零件組合後能正常運作。記住:好的整合測試能抓到單元測試漏掉的問題!
明天我們將探討「效能測試」,學習如何確保應用的回應速度!
記住:整合測試是信心的來源,它證明你的程式真的能用! 💪