跳至主要內容
技術

測試不是選配:用 Pest 寫出有信心的 Laravel 程式

測試不是選配:用 Pest 寫出有信心的 Laravel 程式
PHP/Laravel 完全指南 第 13 / 15 篇

本系列以 Laravel 12 為基準撰寫。Laravel 13 已於 2026 年 3 月 17 日正式發佈(最低需求 PHP 8.3),但本章的 Pest 測試內容在 Laravel 13 同樣適用,不需特別調整。

「在我電腦上可以跑啊。」——這大概是軟體開發史上最經典的一句話。你改了一個 Model 的欄位,結果另一個頁面的表單壞了;你重構了一段商業邏輯,結果付款流程默默失效了。沒有測試的程式碼,每一次修改都是一場賭博,而你遲早會輸。

PHP 社群過去對測試的態度確實比較隨意,但 Pest 測試框架的出現改變了這件事。Pest 是建立在 PHPUnit 之上的現代測試框架,語法簡潔到你會覺得寫測試跟寫文件一樣自然。搭配 Laravel 內建的測試工具,寫測試的門檻已經低到沒有藉口不寫了:

  • HTTP 測試:模擬使用者操作,不需要真的開瀏覽器
  • RefreshDatabase:讓每次測試都從乾淨的資料庫開始
  • Mail::fake():攔截寄信動作,不會真的發出 email
  • Queue::fake():攔截佇列工作,不需要跑 worker

這一章我們要為揪好買的核心流程寫完整測試:開團建立、跟團加入、成團確認、付款流程。然後把這些測試串進 GitHub Actions CI pipeline,讓每一次 Push 都自動跑測試。從此以後,你可以安心重構、放心部署,晚上也能睡好覺。

為什麼 Laravel 專案一定要寫測試:不是為了考試,是為了睡好覺

先講兩個真實場景。

場景一: 某電商平台的工程師收到需求——「折扣碼不能跟團購優惠同時使用」。他改了結帳 Controller 裡的幾行邏輯,本機測了一下沒問題就部署了。隔天早上,客服收到 50 通投訴:「我加購商品怎麼價格變成零?」。原來改動影響了加購邏輯的金額計算,而沒有人發現——因為沒有測試。

場景二: 另一個團隊要把 Laravel 從 10 升級到 11。他們有 400 多個測試案例,跑一次 3 分鐘。升級完跑測試,紅了 12 個。每一個紅掉的測試都精確地告訴他們哪裡壞了、預期行為是什麼。他們花了一個下午修完,信心滿滿地部署上線。

測試的價值不在於「證明程式碼正確」——你永遠無法證明複雜系統沒有 bug。測試的價值在三件事:

1. 安全網:讓你敢重構。 沒有測試的程式碼,大家只敢「加程式碼」,不敢「改程式碼」。久了之後,程式碼就變成一堆層層疊加的 if-else 怪物。有測試保護,你可以放心把醜陋的程式碼重構成漂亮的架構,因為跑一次測試就知道有沒有搞壞東西。

2. 活文件:測試本身就是規格書。 你看一個 Controller 的程式碼,可能要花 10 分鐘才搞懂它的行為。但你看對應的測試——「未登入的使用者嘗試開團時應該被導向登入頁」「開團時名稱是必填欄位」「團購人數已滿時不能再加入」——30 秒就知道這個功能在幹嘛。而且這份文件是「可以執行的」,它不會跟程式碼脫節。

3. 設計回饋:難測的程式碼通常設計不好。 如果你發現一個 class 很難寫測試——需要 mock 一堆東西、需要準備很多前置狀態——那通常代表這個 class 做了太多事情。測試會倒逼你寫出鬆耦合、職責分明的程式碼。

跨框架對照

概念Laravel (Pest)Jest (Node.js)pytest (Python)
測試框架Pest / PHPUnitJest / Vitestpytest
斷言語法expect($x)->toBe(1)expect(x).toBe(1)assert x == 1
HTTP 測試$this->get('/api')supertestTestClient
測試資料庫RefreshDatabase自己管fixtures
MockMail::fake()jest.mock()unittest.mock
執行指令php artisan testnpm testpytest

如果你來自 JavaScript 世界,Pest 的語法會讓你非常有親切感——它就是 PHP 版的 Jest。

Pest 測試框架:比 PHPUnit 更好寫

Laravel 12 的 laravel new 安裝精靈會詢問你選擇 Pest 還是 PHPUnit;選了 Pest,它就會自動裝好、開箱即用。如果你用的是舊版 Laravel 或想手動安裝:

composer require pestphp/pest pestphp/pest-plugin-laravel --dev --with-all-dependencies
php artisan pest:install

pest-plugin-laravel 提供 actingAs()、Laravel 專屬斷言等整合功能;初始化建議用 php artisan pest:install(舊式 ./vendor/bin/pest --init 仍可用,但新版以 pest:install 為主)。

以 Laravel 12 來說,只要在 laravel new 時選 Pest,就能直接使用,不需要額外執行上面的指令。

Pest 語法 vs PHPUnit 語法

先來看同一個測試用兩種方式怎麼寫:

PHPUnit(傳統方式):

<?php

namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;

class GroupBuyTest extends TestCase
{
    use RefreshDatabase;

    public function test_homepage_returns_successful_response(): void
    {
        $response = $this->get('/');

        $response->assertStatus(200);
    }

    public function test_guest_cannot_create_group_buy(): void
    {
        $response = $this->post('/group-buys', [
            'title' => '大湖草莓團購',
        ]);

        $response->assertRedirect('/login');
    }
}

Pest(現代方式):

<?php

use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

it('returns successful response for homepage', function () {
    $this->get('/')
        ->assertStatus(200);
});

it('prevents guest from creating group buy', function () {
    $this->post('/group-buys', [
        'title' => '大湖草莓團購',
    ])->assertRedirect('/login');
});

差異一目了然:

  • 不需要 class —— 每個測試檔案就是一堆函數,不用寫 extends TestCase
  • 不需要 method 命名規範 —— 用 it()test() 描述行為,讀起來像英文句子
  • 不需要 use trait —— uses(RefreshDatabase::class) 一行搞定
  • 鏈式斷言 —— 所有東西串在一起,更流暢

it()test() 的差別

// 這兩個完全等價:
it('creates a group buy successfully', function () { /* ... */ });
test('creates a group buy successfully', function () { /* ... */ });

// it() 在終端顯示為 "it creates a group buy successfully"
// test() 在終端顯示為 "creates a group buy successfully"

慣例上,it() 用來描述「這個東西應該做什麼」,test() 用來描述「做這件事的結果」。選一種風格並保持一致就好。本章統一用 it()

expect() API——流暢的斷言

Pest 的 expect() API 借鏡了 Jest,讀起來非常自然:

// 基本型別
expect($user->name)->toBe('Bobo');
expect($user->age)->toBeGreaterThan(18);
expect($user->email)->toContain('@');
expect($user->bio)->toBeNull();
expect($user->is_active)->toBeTrue();

// 陣列
expect($tags)->toHaveCount(3);
expect($response->json())->toHaveKey('data');
expect($ids)->toContain(1, 2, 3);

// 例外
expect(fn () => $service->join($closedGroupBuy))
    ->toThrow(GroupBuyClosedException::class);

// 鏈式
expect($user)
    ->name->toBe('Bobo')
    ->email->toEndWith('@example.com')
    ->role->toBe(UserRole::Organizer);

跑測試

# 用 Artisan(推薦,有漂亮的輸出)
php artisan test

# 直接跑 Pest
./vendor/bin/pest

# 跑特定檔案
php artisan test --filter=GroupBuyTest

# 跑特定測試
php artisan test --filter="creates a group buy"

# 平行執行(更快)
php artisan test --parallel

# 只跑上次失敗的
php artisan test --retry

php artisan test 的輸出非常漂亮——綠色的勾勾代表通過,紅色的叉叉代表失敗,還會告訴你每個測試花了多少毫秒。

Feature Test vs Unit Test:差在哪裡

Laravel 的測試資料夾結構很清楚:

tests/
├── Feature/          # 功能測試(模擬完整 HTTP 請求)
│   ├── GroupBuyTest.php
│   └── Auth/
│       └── LoginTest.php
├── Unit/             # 單元測試(測試單一 class/method)
│   └── Models/
│       └── GroupBuyTest.php
├── Pest.php          # Pest 全域設定
└── TestCase.php      # 基底 TestCase

Feature Test(功能測試)

功能測試模擬完整的 HTTP 請求生命週期——從路由解析、middleware 檢查、Controller 執行、到 Response 返回。它測試的是「使用者做了某個操作,系統會有什麼反應」:

// tests/Feature/GroupBuyTest.php
it('allows organizer to create a group buy', function () {
    $user = User::factory()->create(['role' => 'organizer']);

    $this->actingAs($user)
        ->post('/group-buys', [
            'title' => '大湖草莓團購',
            'description' => '又到了草莓季',
            'min_participants' => 10,
            'max_participants' => 50,
            'deadline' => now()->addWeek(),
        ])
        ->assertRedirect('/group-buys');

    $this->assertDatabaseHas('group_buys', [
        'title' => '大湖草莓團購',
        'user_id' => $user->id,
    ]);
});

這一個測試涵蓋了:路由是否正確、auth middleware 是否通過、Controller 是否正確處理資料、資料是否寫入資料庫、回應是否重導到正確頁面。

Unit Test(單元測試)

單元測試只測試一個 class 或 method 的行為,不經過 HTTP 層,不碰資料庫(除非必要):

// tests/Unit/Models/GroupBuyTest.php
it('calculates if group buy has reached minimum participants', function () {
    $groupBuy = new GroupBuy([
        'min_participants' => 10,
    ]);

    // 用 mock 或直接設定 relationship count
    expect($groupBuy->hasReachedMinimum(8))->toBeFalse();
    expect($groupBuy->hasReachedMinimum(10))->toBeTrue();
    expect($groupBuy->hasReachedMinimum(15))->toBeTrue();
});

it('determines if group buy is still open', function () {
    $open = new GroupBuy(['deadline' => now()->addDay()]);
    $closed = new GroupBuy(['deadline' => now()->subDay()]);

    expect($open->isOpen())->toBeTrue();
    expect($closed->isOpen())->toBeFalse();
});

什麼時候用哪個?

情境選擇原因
測試 API endpointFeature需要完整 HTTP 生命週期
測試使用者流程Feature涉及多個元件協作
測試 Model 的計算邏輯Unit純函數,不需要 HTTP
測試 Service classUnit單一職責,注入依賴
測試授權 PolicyFeature需要 auth 上下文
測試 Validation 規則Feature需要 Request 處理

經驗法則: 對於 Web 應用程式,Feature Test 的投資報酬率最高。一個 Feature Test 就能涵蓋路由、middleware、Controller、Model、View 的整合。先把核心流程的 Feature Test 寫好,再慢慢補 Unit Test 給複雜的商業邏輯。

Pest.php:全域設定

tests/Pest.php 是 Pest 的全域設定檔,你可以在這裡為不同資料夾的測試統一套用 trait:

// tests/Pest.php

uses(Tests\TestCase::class, Illuminate\Foundation\Testing\RefreshDatabase::class)
    ->in('Feature');

uses(Tests\TestCase::class)
    ->in('Unit');

這樣你就不需要在每個 Feature 測試檔案裡都寫 uses(RefreshDatabase::class) 了——全域一次搞定。

HTTP Tests:模擬使用者操作

Laravel 的 HTTP 測試是你最常用的武器。它讓你模擬瀏覽器的行為——送出 GET、POST、PUT、DELETE 請求,然後檢查回應。

基本 HTTP 方法

// GET 請求——瀏覽頁面
$this->get('/group-buys')
    ->assertStatus(200)
    ->assertSee('大湖草莓團購');

// POST 請求——建立資源
$this->post('/group-buys', [
    'title' => '大湖草莓團購',
    'min_participants' => 10,
])->assertRedirect('/group-buys');

// PUT 請求——更新資源
$this->put("/group-buys/{$groupBuy->id}", [
    'title' => '大湖有機草莓團購',
])->assertRedirect("/group-buys/{$groupBuy->id}");

// DELETE 請求——刪除資源
$this->delete("/group-buys/{$groupBuy->id}")
    ->assertRedirect('/group-buys');

常用斷言方法

// 狀態碼
->assertStatus(200)
->assertOk()               // 等同 assertStatus(200)
->assertNotFound()          // 等同 assertStatus(404)
->assertForbidden()         // 等同 assertStatus(403)
->assertUnauthorized()      // 等同 assertStatus(401)

// 重導向
->assertRedirect('/login')
->assertRedirectToRoute('group-buys.index')

// 頁面內容
->assertSee('大湖草莓團購')           // 頁面包含這段文字
->assertDontSee('已截止')             // 頁面不包含這段文字
->assertSeeText('10 人成團')          // 只看純文字(忽略 HTML)

// Session
->assertSessionHas('success', '開團成功!')
->assertSessionHasErrors(['title'])   // 驗證失敗時的錯誤欄位
->assertSessionHasNoErrors()

// View
->assertViewIs('group-buys.show')
->assertViewHas('groupBuy')

模擬登入使用者

use App\Models\User;

it('shows create form to organizers', function () {
    $organizer = User::factory()->create(['role' => 'organizer']);

    $this->actingAs($organizer)
        ->get('/group-buys/create')
        ->assertOk();
});

it('denies create form to regular members', function () {
    $member = User::factory()->create(['role' => 'member']);

    $this->actingAs($member)
        ->get('/group-buys/create')
        ->assertForbidden();
});

it('redirects guest to login', function () {
    $this->get('/group-buys/create')
        ->assertRedirect('/login');
});

actingAs() 幫你模擬「以某個使用者身份登入」,不需要真的跑登入流程。

測試 JSON API 回應

it('returns group buys as JSON', function () {
    GroupBuy::factory()->count(3)->create();

    $this->getJson('/api/group-buys')
        ->assertOk()
        ->assertJsonCount(3, 'data')
        ->assertJsonStructure([
            'data' => [
                '*' => ['id', 'title', 'description', 'min_participants', 'deadline'],
            ],
        ]);
});

it('returns specific group buy details', function () {
    $groupBuy = GroupBuy::factory()->create([
        'title' => '大湖草莓團購',
    ]);

    $this->getJson("/api/group-buys/{$groupBuy->id}")
        ->assertOk()
        ->assertJson([
            'data' => [
                'title' => '大湖草莓團購',
            ],
        ]);
});

注意這裡用的是 getJson() 而不是 get()——它會自動帶上 Accept: application/json header,讓 Laravel 回傳 JSON 而不是 HTML。對應的還有 postJson()putJson()deleteJson()

Database Testing:RefreshDatabase 的魔力

測試最怕的是「測試之間互相影響」。你在測試 A 建了一個使用者,結果測試 B 因為資料庫裡多了這筆資料而失敗——這種問題 debug 起來讓人抓狂。

RefreshDatabase trait

RefreshDatabase 解決這個問題的方式很聰明:它在每個測試之前用資料庫 transaction 包起來,測試結束後 rollback。效果等同於每個測試都從空白資料庫開始,但速度比真的 migrate:fresh 快得多。

如果你按前面建議設定了 tests/Pest.php,所有 Feature 測試都自動有這個行為:

// tests/Pest.php
uses(Tests\TestCase::class, RefreshDatabase::class)->in('Feature');

資料庫斷言

use App\Models\GroupBuy;
use App\Models\User;

it('stores group buy in database', function () {
    $user = User::factory()->create(['role' => 'organizer']);

    $this->actingAs($user)->post('/group-buys', [
        'title' => '大湖草莓團購',
        'description' => '又到了草莓季',
        'min_participants' => 10,
        'max_participants' => 50,
        'deadline' => '2026-12-31',
    ]);

    // 確認資料庫裡有這筆資料
    $this->assertDatabaseHas('group_buys', [
        'title' => '大湖草莓團購',
        'user_id' => $user->id,
    ]);

    // 確認資料庫裡有正確的數量
    $this->assertDatabaseCount('group_buys', 1);
});

it('removes group buy from database on delete', function () {
    $user = User::factory()->create(['role' => 'organizer']);
    $groupBuy = GroupBuy::factory()->for($user)->create();

    $this->actingAs($user)->delete("/group-buys/{$groupBuy->id}");

    // 確認資料庫裡沒有這筆資料了
    $this->assertDatabaseMissing('group_buys', [
        'id' => $groupBuy->id,
    ]);
});

使用 SQLite in-memory 加速測試

phpunit.xml 裡(Laravel 12 預設已經設好了),測試環境使用 SQLite in-memory 資料庫,跑起來飛快:

<php>
    <env name="APP_ENV" value="testing"/>
    <env name="DB_CONNECTION" value="sqlite"/>
    <env name="DB_DATABASE" value=":memory:"/>
</php>

這代表測試不會碰到你的開發資料庫——完全隔離。

Mock 與 Fake:Mail::fake()、Queue::fake()

測試不應該真的寄信、真的打第三方 API、真的觸發排程任務。Laravel 的 Fake 機制讓你攔截這些副作用,只檢查「系統是否正確地觸發了這些操作」。

Mail::fake()

use Illuminate\Support\Facades\Mail;
use App\Mail\GroupBuyConfirmed;

it('sends confirmation email when group buy reaches minimum', function () {
    Mail::fake();

    $groupBuy = GroupBuy::factory()->create(['min_participants' => 2]);
    $participants = User::factory()->count(2)->create();

    // 模擬兩個人加入,觸發成團
    foreach ($participants as $participant) {
        $groupBuy->participants()->attach($participant);
    }

    $groupBuy->checkAndConfirm();

    // 斷言:確認信被寄出了
    Mail::assertSent(GroupBuyConfirmed::class, function ($mail) use ($groupBuy) {
        return $mail->groupBuy->id === $groupBuy->id;
    });

    // 斷言:寄了正確的數量
    Mail::assertSent(GroupBuyConfirmed::class, 2);
});

it('does not send email when minimum not reached', function () {
    Mail::fake();

    $groupBuy = GroupBuy::factory()->create(['min_participants' => 10]);
    $groupBuy->participants()->attach(User::factory()->create());

    $groupBuy->checkAndConfirm();

    Mail::assertNotSent(GroupBuyConfirmed::class);
});

Mail::fake() 攔截所有郵件,不會真的送出。你只需要驗證「正確的 Mailable 是否被送出、送給了誰」。

Queue::fake()

use Illuminate\Support\Facades\Queue;
use App\Jobs\ProcessGroupBuyPayment;

it('dispatches payment job when group buy is confirmed', function () {
    Queue::fake();

    $groupBuy = GroupBuy::factory()->confirmed()->create();

    $groupBuy->processPayments();

    Queue::assertPushed(ProcessGroupBuyPayment::class, function ($job) use ($groupBuy) {
        return $job->groupBuyId === $groupBuy->id;
    });
});

Notification::fake()

use Illuminate\Support\Facades\Notification;
use App\Notifications\GroupBuyDeadlineReminder;

it('sends deadline reminder to all participants', function () {
    Notification::fake();

    $groupBuy = GroupBuy::factory()->create([
        'deadline' => now()->addDay(),
    ]);
    $participants = User::factory()->count(5)->create();
    $groupBuy->participants()->attach($participants);

    $groupBuy->sendDeadlineReminders();

    Notification::assertSentTo($participants, GroupBuyDeadlineReminder::class);
});

Event::fake()

use Illuminate\Support\Facades\Event;
use App\Events\GroupBuyCreated;

it('fires event when group buy is created', function () {
    Event::fake([GroupBuyCreated::class]);

    $user = User::factory()->create(['role' => 'organizer']);

    $this->actingAs($user)->post('/group-buys', [
        'title' => '大湖草莓團購',
        'min_participants' => 10,
        'max_participants' => 50,
        'deadline' => now()->addWeek(),
    ]);

    Event::assertDispatched(GroupBuyCreated::class);
});

Storage::fake()

use Illuminate\Support\Facades\Storage;
use Illuminate\Http\UploadedFile;

it('uploads product image when creating group buy', function () {
    Storage::fake('public');

    $user = User::factory()->create(['role' => 'organizer']);
    $image = UploadedFile::fake()->image('strawberry.jpg', 800, 600);

    $this->actingAs($user)->post('/group-buys', [
        'title' => '大湖草莓團購',
        'image' => $image,
        'min_participants' => 10,
        'max_participants' => 50,
        'deadline' => now()->addWeek(),
    ]);

    // 斷言:檔案確實被存到 public disk
    Storage::disk('public')->assertExists('group-buys/' . $image->hashName());
});

Storage::fake('public') 建立一個 in-memory 檔案系統,不會真的寫檔案到磁碟。測試結束後自動清空。

測試資料準備:Factory 的進階用法

第五章已經介紹過 Factory 的基本用法。在測試情境裡,Factory 的進階功能會讓你的測試更簡潔、更有表達力。

Factory States:用名稱描述狀態

// database/factories/GroupBuyFactory.php

class GroupBuyFactory extends Factory
{
    public function definition(): array
    {
        return [
            'user_id' => User::factory(),
            'title' => fake()->sentence(3),
            'description' => fake()->paragraph(),
            'min_participants' => fake()->numberBetween(5, 20),
            'max_participants' => fake()->numberBetween(20, 100),
            'deadline' => fake()->dateTimeBetween('+1 week', '+1 month'),
            'status' => 'open',
        ];
    }

    // State:已確認成團
    public function confirmed(): static
    {
        return $this->state(fn (array $attributes) => [
            'status' => 'confirmed',
            'confirmed_at' => now(),
        ]);
    }

    // State:已截止
    public function expired(): static
    {
        return $this->state(fn (array $attributes) => [
            'deadline' => now()->subDay(),
            'status' => 'expired',
        ]);
    }

    // State:已取消
    public function cancelled(): static
    {
        return $this->state(fn (array $attributes) => [
            'status' => 'cancelled',
            'cancelled_at' => now(),
        ]);
    }

    // State:已滿團
    public function full(): static
    {
        return $this->state(fn (array $attributes) => [
            'max_participants' => 2,
        ])->afterCreating(function (GroupBuy $groupBuy) {
            $groupBuy->participants()->attach(
                User::factory()->count(2)->create()
            );
        });
    }
}

使用起來非常直覺:

$openGroupBuy = GroupBuy::factory()->create();
$confirmedGroupBuy = GroupBuy::factory()->confirmed()->create();
$expiredGroupBuy = GroupBuy::factory()->expired()->create();
$fullGroupBuy = GroupBuy::factory()->full()->create();

Factory Relationships:has()for()

// 建立一個開團主,有 3 個團購
$organizer = User::factory()
    ->has(GroupBuy::factory()->count(3))
    ->create(['role' => 'organizer']);

// 建立一個團購,屬於特定使用者
$groupBuy = GroupBuy::factory()
    ->for($organizer)
    ->create();

// 建立一個團購,帶有 5 個參與者
$groupBuy = GroupBuy::factory()
    ->hasParticipants(5)     // 等同 has(User::factory()->count(5), 'participants')
    ->create();

Factory Sequences:輪替值

// 交替建立不同狀態的團購
$groupBuys = GroupBuy::factory()
    ->count(6)
    ->sequence(
        ['status' => 'open'],
        ['status' => 'confirmed'],
        ['status' => 'expired'],
    )
    ->create();
// 結果:open, confirmed, expired, open, confirmed, expired

recycle():在多個 Factory 之間共用 Model

// 同一個使用者同時是開團主和其他團的參與者
$user = User::factory()->create();

$groupBuys = GroupBuy::factory()
    ->count(3)
    ->recycle($user)   // 所有 user_id 都用這個使用者
    ->create();

recycle() 避免了 Factory 每次都建一個新的關聯 Model——當你需要多個 Factory 共用同一筆資料時特別有用。

GitHub Actions CI:每次 Push 自動跑測試

測試寫好了,但如果要「記得手動跑」才有用,那遲早會有人忘記。CI(Continuous Integration)的目的就是:每次有人 push 程式碼,自動跑測試。測試過了才能合併。

完整的 GitHub Actions 設定

在專案根目錄建立 .github/workflows/tests.yml

name: Tests

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  tests:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          extensions: mbstring, xml, ctype, json, bcmath, sqlite3
          coverage: none

      - name: Cache Composer dependencies
        uses: actions/cache@v4
        with:
          path: vendor
          key: composer-${{ hashFiles('composer.lock') }}
          restore-keys: composer-

      - name: Install dependencies
        run: composer install --no-interaction --prefer-dist --optimize-autoloader

      - name: Copy environment file
        run: cp .env.example .env

      - name: Generate application key
        run: php artisan key:generate

      - name: Run tests
        env:
          DB_CONNECTION: sqlite
          DB_DATABASE: ':memory:'
        run: php artisan test --parallel

逐步拆解:

  1. 觸發條件 —— push 到 main 或開 PR 到 main 時觸發
  2. PHP 設定 —— 安裝 PHP 8.4 和必要的擴充套件
  3. Composer Cache —— 快取 vendor/ 目錄,避免每次都重裝套件(省 30-60 秒)
  4. SQLite in-memory —— 不需要真的 MySQL server,用 SQLite 跑測試更快
  5. 平行執行 —— --parallel 讓測試跑更快

把 Badge 加到 README

![Tests](https://github.com/your-username/jiu-hao-mai/actions/workflows/tests.yml/badge.svg)

這個 badge 會顯示在 README 最上方,讓所有人一眼就知道測試有沒有通過。綠色的 “passing” 就是信任的象徵。

當 CI 失敗了

CI 紅了不可怕,可怕的是忽視它。當 CI 失敗時:

  1. 點進 GitHub Actions 看 log —— 找到紅色的步驟,看錯誤訊息
  2. 在本機重現 —— php artisan test --filter="失敗的測試名稱"
  3. 修好它 —— 不要跳過失敗的測試(->skip()),除非有非常正當的理由
  4. Push 修正 —— CI 會自動重新跑

絕對不要做的事: 刪掉失敗的測試、在 CI 裡加 continue-on-error: true、或用 @skip 跳過。這些做法只是在掩耳盜鈴。

實作:為揪好買核心流程寫測試

理論講完了,現在動手。我們要為揪好買寫一組完整的 Feature Test,覆蓋最重要的使用者流程。

建立測試檔案:

php artisan make:test GroupBuyTest
# 生成 tests/Feature/GroupBuyTest.php
<?php

// tests/Feature/GroupBuyTest.php

use App\Models\User;
use App\Models\GroupBuy;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use App\Mail\GroupBuyConfirmed;
use App\Notifications\GroupBuyJoined;

// ── 開團建立 ──────────────────────────────────────

it('allows authenticated organizer to create a group buy', function () {
    $organizer = User::factory()->create(['role' => 'organizer']);

    $this->actingAs($organizer)
        ->post('/group-buys', [
            'title' => '大湖草莓團購',
            'description' => '苗栗大湖有機草莓,產地直送',
            'min_participants' => 10,
            'max_participants' => 50,
            'deadline' => now()->addWeek()->toDateString(),
        ])
        ->assertRedirect('/group-buys');

    $this->assertDatabaseHas('group_buys', [
        'title' => '大湖草莓團購',
        'user_id' => $organizer->id,
        'status' => 'open',
    ]);
});

it('rejects group buy creation with missing required fields', function () {
    $organizer = User::factory()->create(['role' => 'organizer']);

    $this->actingAs($organizer)
        ->post('/group-buys', [
            // title 沒填
            'min_participants' => 10,
        ])
        ->assertSessionHasErrors(['title', 'max_participants', 'deadline']);
});

it('prevents guest from creating a group buy', function () {
    $this->post('/group-buys', [
        'title' => '大湖草莓團購',
        'min_participants' => 10,
        'max_participants' => 50,
        'deadline' => now()->addWeek()->toDateString(),
    ])->assertRedirect('/login');
});

it('prevents regular member from creating a group buy', function () {
    $member = User::factory()->create(['role' => 'member']);

    $this->actingAs($member)
        ->post('/group-buys', [
            'title' => '大湖草莓團購',
            'min_participants' => 10,
            'max_participants' => 50,
            'deadline' => now()->addWeek()->toDateString(),
        ])
        ->assertForbidden();
});

// ── 跟團加入 ──────────────────────────────────────

it('allows authenticated user to join an open group buy', function () {
    $user = User::factory()->create();
    $groupBuy = GroupBuy::factory()->create(['status' => 'open']);

    $this->actingAs($user)
        ->post("/group-buys/{$groupBuy->id}/join")
        ->assertRedirect("/group-buys/{$groupBuy->id}");

    $this->assertDatabaseHas('group_buy_user', [
        'user_id' => $user->id,
        'group_buy_id' => $groupBuy->id,
    ]);
});

it('prevents joining a group buy that is already full', function () {
    $user = User::factory()->create();
    $groupBuy = GroupBuy::factory()->full()->create();

    $this->actingAs($user)
        ->post("/group-buys/{$groupBuy->id}/join")
        ->assertStatus(422)
        ->assertSessionHasErrors(['capacity']);
});

it('prevents duplicate join to the same group buy', function () {
    $user = User::factory()->create();
    $groupBuy = GroupBuy::factory()->create(['status' => 'open']);
    $groupBuy->participants()->attach($user);

    $this->actingAs($user)
        ->post("/group-buys/{$groupBuy->id}/join")
        ->assertStatus(422)
        ->assertSessionHasErrors(['duplicate']);
});

// ── 成團確認 ──────────────────────────────────────

it('confirms group buy when minimum participants reached', function () {
    Mail::fake();

    $groupBuy = GroupBuy::factory()->create([
        'min_participants' => 3,
        'status' => 'open',
    ]);

    $participants = User::factory()->count(3)->create();
    foreach ($participants as $participant) {
        $groupBuy->participants()->attach($participant);
    }

    $groupBuy->checkAndConfirm();

    expect($groupBuy->fresh()->status)->toBe('confirmed');

    Mail::assertSent(GroupBuyConfirmed::class, 3);
});

it('does not confirm group buy below minimum participants', function () {
    $groupBuy = GroupBuy::factory()->create([
        'min_participants' => 10,
        'status' => 'open',
    ]);

    $groupBuy->participants()->attach(User::factory()->create());

    $groupBuy->checkAndConfirm();

    expect($groupBuy->fresh()->status)->toBe('open');
});

// ── API Endpoints ─────────────────────────────────

it('returns paginated group buys as JSON', function () {
    GroupBuy::factory()->count(15)->create(['status' => 'open']);

    $this->getJson('/api/group-buys')
        ->assertOk()
        ->assertJsonStructure([
            'data' => [
                '*' => [
                    'id',
                    'title',
                    'description',
                    'min_participants',
                    'max_participants',
                    'deadline',
                    'status',
                    'participants_count',
                ],
            ],
            'meta' => ['current_page', 'last_page', 'total'],
        ]);
});

it('requires authentication for API group buy creation', function () {
    $this->postJson('/api/group-buys', [
        'title' => '大湖草莓團購',
    ])->assertUnauthorized();
});

讓我們回顧這 11 個測試案例涵蓋了什麼:

#測試案例驗證重點
1開團主成功開團認證 + 授權 + 資料寫入
2缺少必填欄位表單驗證
3未登入者嘗試開團認證 middleware
4一般會員嘗試開團授權 Policy
5加入開放中的團購正常流程 + pivot table
6加入已滿的團購容量限制
7重複加入同一團購業務規則
8成團確認 + 寄信商業邏輯 + Mail::fake
9未達最低人數不成團邊界條件
10API 回應結構JSON 格式 + 分頁
11API 認證Token 認證

跑測試:

php artisan test tests/Feature/GroupBuyTest.php

#  PASS  Tests\Feature\GroupBuyTest
#  ✓ it allows authenticated organizer to create a group buy      0.15s
#  ✓ it rejects group buy creation with missing required fields   0.08s
#  ✓ it prevents guest from creating a group buy                  0.05s
#  ✓ it prevents regular member from creating a group buy         0.06s
#  ✓ it allows authenticated user to join an open group buy       0.09s
#  ✓ it prevents joining a group buy that is already full         0.07s
#  ✓ it prevents duplicate join to the same group buy             0.06s
#  ✓ it confirms group buy when minimum participants reached      0.11s
#  ✓ it does not confirm group buy below minimum participants     0.07s
#  ✓ it returns paginated group buys as JSON                      0.12s
#  ✓ it requires authentication for API group buy creation        0.04s
#
#  Tests:    11 passed (23 assertions)
#  Duration: 0.90s

全綠。11 個測試、23 個斷言、不到 1 秒。這就是你的安全網。

小結:有測試的程式碼,才是專業的程式碼

這一章我們建立了完整的測試體系:

測試框架:

  • Pest 是 Laravel 12 官方一級支援、可在建立專案時直接選用的框架——語法簡潔、讀起來像英文、expect() API 直覺好用
  • it() 描述行為、expect() 驗證結果、uses() 套用 trait
  • php artisan test 一行指令跑所有測試

測試類型:

  • Feature Test 模擬完整 HTTP 請求,投資報酬率最高
  • Unit Test 測試單一 class/method 的純邏輯
  • 先寫 Feature Test 覆蓋核心流程,再補 Unit Test 給複雜邏輯

測試工具:

  • actingAs() 模擬登入使用者
  • RefreshDatabase 確保每個測試從乾淨資料庫開始
  • Mail::fake()Queue::fake()Notification::fake() 攔截副作用
  • Storage::fake() 建立 in-memory 檔案系統
  • Factory States 讓測試資料準備更有表達力

CI Pipeline:

  • GitHub Actions 讓每次 push 自動跑測試
  • SQLite in-memory + Composer cache 讓 CI 跑得快
  • 測試紅了就修,不要跳過、不要忽視

揪好買進度:

  • ✅ 11 個 Feature Test 涵蓋開團、跟團、成團、API
  • ✅ Factory States 定義 confirmed()expired()full() 等狀態
  • ✅ GitHub Actions CI pipeline 設定完成
  • ✅ 核心流程全部有測試保護

有了測試保護,你就可以放心做任何改動。下一章我們要把揪好買部署上線——從 Production 環境設定、Config Cache、到 Laravel Forge 一鍵部署和 Docker 容器化。從此以後,你的程式碼不只在本機能跑,在全世界都能跑。

留言討論

esc
輸入關鍵字搜尋文章...
查看收藏 →