iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
Modern Web

我推的Laravel S2!系列 第 28

我推的Laravel S2|Day 27:Testing 測試:TEST CASE、HTTP 斷言、資料庫測試

  • 分享至 

  • xImage
  •  

一句話破題

這系列從 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

Unit Test 跟 Feature Test 的差異

tests/ 目錄下預設有 Unit/Feature/ 兩個資料夾,這個劃分反映測試關注的層次不同:

  • Unit Test:測試單一類別/方法的邏輯,不啟動完整的 Laravel 應用程式(速度快,但不驗證跟框架其他部分的整合)。適合測試 Day 17 的自訂驗證規則、Day 8 那種純函式邏輯。
  • Feature Test:透過實際發出 HTTP 請求(或呼叫 Artisan 指令)測試完整的行為,會啟動整個應用程式(速度較慢,但更貼近真實使用情境)。適合測試 Day 13 的 Controller、Day 14 的 API 端點。

實務上多數 Laravel 專案的測試以 Feature Test 為主——因為 Laravel 的價值很大一部分來自框架各部分的整合(路由、中介層、Eloquent、驗證都要一起運作才有意義),純粹測試孤立的類別方法反而不容易抓到「這些部分組合起來到底對不對」的問題。今天的範例也以 Feature Test 為主。

HTTP Feature Test:完整走一遍 Post CRUD

// 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 裡最常組合使用的工具。

Pest 的共用邏輯:beforeEach 與資料集

如果多個測試需要相同的前置準備(例如都需要一個已登入的使用者),用 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([...]) 定義的每一組資料都會各自跑一次測試,測試報告裡會分別顯示每組資料的成功/失敗狀態,比手動複製貼上三個幾乎一樣的測試方法乾淨得多。

Assertion 精選速查表

assert* 方法很多,這裡挑最常用的四大分類:

HTTP 狀態碼類

$response->assertOk();               // 200
$response->assertCreated();          // 201
$response->assertNoContent();        // 204
$response->assertNotFound();         // 404
$response->assertForbidden();        // 403
$response->assertUnauthorized();     // 401

內容比對類

$response->assertSee('我的第一篇文章');
$response->assertDontSee('敏感字詞');
$response->assertSeeInOrder(['標題一', '標題二']);

JSON 比對類

$response->assertJson(['title' => '我的第一篇文章']);        // 部分符合即可
$response->assertExactJson([...]);                          // 完全一致
$response->assertJsonCount(3, 'data');
$response->assertJsonPath('data.0.title', '我的第一篇文章');
$response->assertJsonValidationErrors(['title']);            // 驗證錯誤

導向與 Session 類

$response->assertRedirect('/posts');
$response->assertRedirectToRoute('posts.index');
$response->assertSessionHas('success');
$response->assertSessionHasErrors(['title']);

Pest 的 expect() 語法:另一種斷言風格

除了上面 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 的驗證規則確實有效——兩者都是「確保系統在不該成功的情況下真的會失敗」,跟一般直覺只測「成功案例」同樣重要,甚至某種程度上更重要,因為失敗路徑的邏輯錯誤往往造成更嚴重的資安或資料完整性問題。

Console Tests:測試 Artisan 指令

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 Actions 為例

實務上測試最大的價值,是搭配持續整合(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 分支永遠是可以部署的穩定狀態」這個原則能真正落實的關鍵機制,不依賴人為記得手動測試。

CI 測試流程:push 或開 PR 觸發 workflow → 安裝 PHP 與相依套件 → 執行 php artisan test → 全部通過才允許合併,有失敗直接在 PR 頁面標示

php artisan test 執行結果的終端機截圖

GitHub Actions 執行結果頁面截圖

PHPUnit / Pest 版本對齊

這是這個章節唯一需要更新的版本資訊,跟框架本身的 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()
  • 只測成功案例,完全沒測失敗/邊界情況:前面提過的重點,「這個操作在不該成功時真的會失敗」跟「這個操作在該成功時真的會成功」同樣重要,只測前者容易讓資安/資料完整性相關的邏輯錯誤悄悄溜過測試。
  • CI 沒有整合測試流程,依賴開發者自己記得手動跑:測試寫了但沒有自動化執行機制,長期而言很容易變成「寫的時候測過、之後改壞了沒人發現」,CI 整合是讓測試真正發揮價值的最後一哩路。

小結

測試案例的核心組成(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 之後可能用到的重要生態系套件盤點一遍。


上一篇
我推的Laravel S2|Day 26:HTTP Client:呼叫外部 API
系列文
我推的Laravel S2!28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言