這系列從 Day 9 開始建立的 Post 相關功能,到今天總算要幫它們寫測試了。Laravel 的 Assertion 方法光是 HTTP 回應就有 40 幾個,今天一樣採策展式寫法,挑最常用的分類講清楚,並補上 PHPUnit/Pest 這幾年的版本對應關係。
不限於 Laravel,任何測試案例都圍繞五個要素:測試目標、前置條件、測試步驟、預期結果、實際結果。Laravel 用 Pest 或 PHPUnit 把這套流程寫成程式碼,blog-app 這系列範例用 Pest(新版 Starter Kit 預設風格,語法更精簡):
php artisan make:test PostTest
php artisan make:test PostApiTest --pest
tests/ 目錄下預設有 Unit/ 跟 Feature/ 兩個資料夾,這個劃分反映測試關注的層次不同:
實務上多數 Laravel 專案的測試以 Feature Test 為主——因為 Laravel 的價值很大一部分來自框架各部分的整合(路由、中介層、Eloquent、驗證都要一起運作才有意義),純粹測試孤立的類別方法反而不容易抓到「這些部分組合起來到底對不對」的問題。今天的範例也以 Feature Test 為主。
// tests/Feature/PostTest.php
use App\Models\Post;
use App\Models\User;
test('已登入使用者可以建立文章', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/posts', [
'title' => '我的第一篇文章',
'body' => '這是文章內容,至少要十個字元以上。',
]);
$response->assertRedirect();
$this->assertDatabaseHas('posts', [
'title' => '我的第一篇文章',
'user_id' => $user->id,
]);
});
test('未登入使用者無法建立文章', function () {
$response = $this->post('/posts', ['title' => '測試', 'body' => '內容內容內容內容']);
$response->assertRedirect('/login');
});
test('文章列表 API 回傳正確結構', function () {
Post::factory()->count(3)->create(['published_at' => now()]);
$response = $this->getJson('/api/posts');
$response->assertOk()
->assertJsonCount(3, 'data')
->assertJsonStructure([
'data' => [['id', 'title', 'body', 'published', 'author' => ['id', 'name']]],
]);
});
actingAs($user) 模擬「已登入使用者」發出請求,Post::factory() 用的是 Day 9 建立 Model 時一併產生的 Factory(PostFactory)快速造測試資料,這兩個是 Feature Test 裡最常組合使用的工具。
如果多個測試需要相同的前置準備(例如都需要一個已登入的使用者),用 beforeEach() 避免每個測試重複寫:
// tests/Pest.php 或個別測試檔案內
beforeEach(function () {
$this->user = User::factory()->create();
});
test('已登入使用者可以建立文章', function () {
$response = $this->actingAs($this->user)->post('/posts', [...]);
$response->assertRedirect();
});
如果同一段測試邏輯要用多組不同的輸入資料驗證(例如驗證規則測試),Pest 提供資料集(dataset)語法,避免複製貼上同一個測試好幾次:
test('標題長度驗證', function (string $title, bool $shouldPass) {
$response = $this->actingAs(User::factory()->create())->post('/posts', [
'title' => $title,
'body' => '內容內容內容內容',
]);
$shouldPass ? $response->assertRedirect() : $response->assertSessionHasErrors('title');
})->with([
['正常標題', true],
['', false], // 空標題應該失敗
[str_repeat('a', 256), false], // 超過 255 字元應該失敗
]);
->with([...]) 定義的每一組資料都會各自跑一次測試,測試報告裡會分別顯示每組資料的成功/失敗狀態,比手動複製貼上三個幾乎一樣的測試方法乾淨得多。
assert* 方法很多,這裡挑最常用的四大分類:
$response->assertOk(); // 200
$response->assertCreated(); // 201
$response->assertNoContent(); // 204
$response->assertNotFound(); // 404
$response->assertForbidden(); // 403
$response->assertUnauthorized(); // 401
$response->assertSee('我的第一篇文章');
$response->assertDontSee('敏感字詞');
$response->assertSeeInOrder(['標題一', '標題二']);
$response->assertJson(['title' => '我的第一篇文章']); // 部分符合即可
$response->assertExactJson([...]); // 完全一致
$response->assertJsonCount(3, 'data');
$response->assertJsonPath('data.0.title', '我的第一篇文章');
$response->assertJsonValidationErrors(['title']); // 驗證錯誤
$response->assertRedirect('/posts');
$response->assertRedirectToRoute('posts.index');
$response->assertSessionHas('success');
$response->assertSessionHasErrors(['title']);
除了上面 Laravel 內建的 assert* 方法,Pest 本身也提供一套鏈式的 expect() 斷言語法,適合測試一般的 PHP 值(不是 HTTP 回應):
test('文章的 slug 會自動從標題產生', function () {
$post = Post::factory()->create(['title' => 'Laravel 13 上手筆記', 'slug' => null]);
expect($post->slug)
->toBeString()
->not->toBeEmpty()
->toContain('laravel');
});
test('已發布文章的 published 欄位是 true', function () {
$post = Post::factory()->published()->make(); // Day 9 定義的 Factory 狀態
expect($post->published_at)->not->toBeNull();
});
兩種語法(assertXxx()/expect()->toXxx())在 Pest 專案裡可以並存,assert* 系列偏向驗證 HTTP 回應/資料庫狀態這種「Laravel 特有」的斷言,expect() 系列偏向驗證一般 PHP 值的邏輯正確性,依情境選用即可,不需要強迫自己只用其中一種。
use Illuminate\Foundation\Testing\RefreshDatabase;
class PostTest extends TestCase
{
use RefreshDatabase; // 每個測試前重置資料庫(跑一次 migration)
}
// Pest 寫法在檔案頂端 uses()
uses(RefreshDatabase::class);
RefreshDatabase 確保每個測試案例都在乾淨的資料庫狀態下執行,測試之間不會互相汙染資料。常用的資料庫斷言:
$this->assertDatabaseHas('posts', ['title' => '我的第一篇文章']);
$this->assertDatabaseMissing('posts', ['title' => '不存在的標題']);
$this->assertDatabaseCount('posts', 3);
$this->assertSoftDeleted('posts', ['id' => $post->id]); // 對應 Day 11 的軟刪除
test('嘗試發布內容過短的文章會拋出例外', function () {
$post = Post::factory()->create(['body' => '太短了']);
$this->expectException(InvalidPostStateException::class);
$post->publish();
});
test('內文過短時驗證會失敗並回傳正確訊息', function () {
$response = $this->actingAs(User::factory()->create())->postJson('/api/posts', [
'title' => '標題',
'body' => '太短',
]);
$response->assertUnprocessable()
->assertJsonValidationErrors(['body']);
});
expectException() 驗證 Day 18 那種自訂例外真的會在預期情境下被拋出;assertJsonValidationErrors() 則是驗證 Day 17 的驗證規則確實有效——兩者都是「確保系統在不該成功的情況下真的會失敗」,跟一般直覺只測「成功案例」同樣重要,甚至某種程度上更重要,因為失敗路徑的邏輯錯誤往往造成更嚴重的資安或資料完整性問題。
Day 25 建立的 posts:prune 指令,也能寫測試:
test('posts:prune 會清除過期草稿', function () {
Post::factory()->create(['published_at' => null, 'created_at' => now()->subMonths(7)]);
Post::factory()->create(['published_at' => now()]);
$this->artisan('posts:prune', ['--months' => 6])
->expectsOutput('已清除 1 篇過期草稿。')
->assertExitCode(0);
$this->assertDatabaseCount('posts', 1);
});
測試數量隨著專案成長會越來越多,執行時間也跟著拉長。Laravel 內建支援平行執行測試:
php artisan test --parallel
php artisan test --parallel --processes=4
平行執行會把測試分配到多個行程同時跑,RefreshDatabase 也會自動為每個行程建立獨立的測試資料庫,避免互相干擾。對測試數量多、CI 流程時間敏感的專案,這是很直接的加速手段。
如果你想知道「程式碼有多少比例被測試涵蓋到」,可以產生覆蓋率報告(需要 Xdebug 或 PCOV 這類覆蓋率擴充套件):
php artisan test --coverage
php artisan test --coverage --min=80 # 設定最低覆蓋率門檻,低於這個比例就讓測試視為失敗
覆蓋率數字本身不是目的(100% 覆蓋率不代表沒有 bug,可能只是每行都執行過一次、但沒驗證邏輯是否正確),但它是一個有用的訊號,能提醒你「這段程式碼完全沒有任何測試碰過」,值得補上。
實務上測試最大的價值,是搭配持續整合(CI)流程,在每次提交程式碼時自動執行,而不是只靠開發者自己記得手動跑:
# .github/workflows/tests.yml
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
- run: composer install --no-interaction --prefer-dist
- run: cp .env.example .env
- run: php artisan key:generate
- run: php artisan test
這份設定檔提交進版本控制後,之後每次推送程式碼、開 Pull Request,GitHub 都會自動跑一次完整測試,測試失敗時直接在 PR 頁面標示出來——這是 Day 29 會提到的團隊協作流程裡,確保「main 分支永遠是可以部署的穩定狀態」這個原則能真正落實的關鍵機制,不依賴人為記得手動測試。



這是這個章節唯一需要更新的版本資訊,跟框架本身的 API 沒有直接關係,但升級 Laravel 大版本時容易被忽略:
| Laravel | PHPUnit | Pest |
|---|---|---|
| 11(發布當時) | ^10.5 | ^2 |
| 12 | ^11.0 | ^3.0 |
| 13 | ^12.0 | ^4.0 |
(Laravel 12、13 兩列是官方升級指南明確列出的版本要求;Laravel 11 這列是發布當時的搭配版本,測試框架本身後續也各自持續推出新版,實際版本以你專案 composer.json 建立當下解析到的版本為準。)
升級 Laravel 大版本時,composer.json 裡這兩個測試框架的版本約束也要一併更新,不然可能遇到相依套件版本衝突無法安裝。這系列從 Day 2 建立的環境是全新的 Laravel 13 專案,composer create-project 建立時就會自動帶入相容的版本,不需要手動處理;只有從舊版專案升級時才需要特別注意這張對照表。
RefreshDatabase,測試之間互相汙染:上一個測試留下的資料影響到下一個測試的斷言結果(例如 assertDatabaseCount 因為前一個測試殘留的資料而數字對不上),是新手很常遇到、但一旦忘記加這個 trait 就會很難排查的問題。assertJson() 誤以為要完全比對:assertJson() 只要求「給定的片段存在」,回應裡有其他額外欄位不影響測試通過;如果你確實要求完全一致(不能有多餘欄位),要用 assertExactJson()。Http::fake()(Day 26)就呼叫了會打外部 API 的程式碼:測試應該是可重複、不依賴外部服務可用性的,任何牽涉到 Http:: 呼叫的程式碼,測試裡都要先 Http::fake()。測試案例的核心組成(HTTP Feature Test、Assertion、資料庫測試)在 Laravel 10 到 13 間維持穩定,今天除了補上 PHPUnit/Pest 版本對照表,也深入了 Unit/Feature Test 的差異、Pest 的資料集與 expect() 語法、例外測試、平行執行加速、以及整合進 CI 流程這幾個讓測試真正發揮長期價值的實務作法。
學而不思則罔,思而不學則殆 — 《論語・為政》
Day 28 進入套件生態管理:Composer 套件衝突處理、Telescope、Sanctum、Octane、Socialite、Laravel Boost,把 blog-app 之後可能用到的重要生態系套件盤點一遍。