Pest 測試紀律
功能在未經測試的情況下上線——或者使用過度模擬、斷言實作細節的「測試」——會在生產環境中默默崩潰,並在每次重構時腐化。這項技能迫使你編寫 Pest 3/4 測試,證明的是行為、涵蓋失敗路徑與授權,並且能夠在重構後存活下來。
陷阱
兩種失敗模式,都代價高昂:
- 沒有測試 / 僅有快樂路徑測試。 功能在示範時正常運作,然後在生產環境中,訪客點擊了端點、某個租戶讀取了另一個租戶的資料、驗證漏洞讓無效資料進入,或者 404 變成了 500。授權漏洞(從未斷言的
403/404路徑)正是資料洩漏進入生產環境的方式。 - 脆弱、過度模擬的測試。 你使用
shouldReceive('save')->once()模擬自己的UserRepository/服務,因此即使真實查詢是錯的,測試也會通過,並且只要有人重新命名方法,它就會崩潰。測試套件顯示綠燈,提供虛假信心,並在下次重構時因挫敗感而被刪除——讓你回到模式 1。
無論哪種情況,解決方案都是同樣的紀律:偽造你不擁有的邊界,執行你擁有的程式碼,並斷言可觀察的結果(資料庫行、HTTP 狀態、分派的工作/郵件)——包括失敗和授權結果。
規則
- 與功能一起編寫測試,而不是「稍後」。 控制器/工作/指令/策略的變更在擁有對應的 Pest 測試之前不算完成。永遠不要將沒有對應測試斷言其行為的功能報告為已完成。
- 使用 Pest 慣用語法。 使用
it('...')或test('...')定義案例;使用流暢的expect(...)進行斷言;使用beforeEach()共享設定。除非檔案已經使用該風格,否則不要編寫 PHPUnitclass FooTest extends TestCase。 - 選擇正確的類型。 HTTP/端到端行為 → 功能測試(啟動框架,透過
$this->get/post/...點擊路由)。沒有框架/資料庫的純邏輯 → 單元測試。不確定時,預設使用功能測試——它能捕捉更多問題。 - 使用 trait 重置資料庫,透過
uses()。 在tests/Pest.php中為整個Feature目錄套用RefreshDatabase(包裝在交易中)或LazilyRefreshDatabase(僅在測試觸及資料庫時才執行遷移),或者在檔案頂部使用uses(RefreshDatabase::class)。永遠不要讓資料庫測試依賴於遺留狀態。 - 使用工廠建立資料,絕不要手動插入。 使用
User::factory()->create()/->make()和關聯工廠。測試中手動建構的DB::table()->insert([...])或new Model([...])->save()很脆弱,會跳過型別轉換/預設值——禁止使用。 - 使用資料集去重,而不是複製貼上。 當同一個斷言在多個輸入上執行時(有效/無效的請求負載、角色矩陣),使用
->with([...])(內聯或tests/Datasets/中的命名資料集),而不是 N 個幾乎相同的測試。 - 偽造你不擁有的邊界。 對於任何外部或非同步的內容,請在動作之前使用 Facade 偽造:
Http::fake()、Queue::fake()、Bus::fake()、Mail::fake()、Notification::fake()、Event::fake()、Storage::fake()。對於時間,使用$this->travel(...)、$this->travelTo(...)或$this->freezeTime()——永遠不要針對未凍結的now()進行斷言。 - 不要將你自己的模型/服務模擬到毫無意義。 避免對被測試的程式碼使用
Mockery::mock(MyService::class)->shouldReceive(...)和Model::shouldReceive(...)。讓真實程式碼針對測試資料庫和偽造的邊界執行。僅在一個你沒有在執行的真正接合處(例如,一個緩慢的第三方 SDK 包裝器)模擬你自己的類別。 - 斷言結果,而不是內部實作。 優先使用
assertDatabaseHas/assertDatabaseMissing/assertModelExists、assertStatus/assertOk/assertCreated、assertJson/assertJsonPath、assertRedirect、Mail::assertSent、Notification::assertSentTo、Queue::assertPushed、Bus::assertDispatched、Storage::disk()->assertExists。不要將「方法 X 被呼叫一次」當作行為的代理進行斷言。 - 始終測試失敗路徑和授權。 每個端點/動作至少需要:快樂路徑、驗證/失敗路徑以及授權路徑——訪客 →
401/重新導向、錯誤的使用者/角色 →403、缺少/不存在的資源 →404。對於任何安全敏感的功能,添加一個明確的「其他使用者無法存取/修改此項」的測試。這是任何涉及所有權或租戶的項目的必要條件。 - 添加架構測試作為廉價的全域護欄。 保留一個
tests/Arch.php(或在Pest.php中包含架構案例)並配備合適的預設值:arch()->preset()->php()(禁止die/var_dump/dd風格的除錯輸出和已棄用的 PHP 函數)、arch()->preset()->security()(禁止eval、extract、unserialize、弱雜湊/md5/sha1、不安全的隨機性等)以及arch()->preset()->laravel()(強制執行 Laravel 慣例——包括在 config 之外禁止使用env())。添加專案不變量,例如arch('controllers')->expect('App\Http\Controllers')->toExtend('App\Http\Controllers\Controller')。 - 每個測試一個行為,使用描述性名稱。
it('blocks guests from the dashboard'),而不是it('works')。如果測試名稱需要「和」,請將其拆分。 - 在 CI 中設定覆蓋率門檻。 執行
./vendor/bin/pest --coverage --min=NN來強制設定最低覆蓋率,以及./vendor/bin/pest --type-coverage(需要 type-coverage 外掛)來強制型別簽名。將新的未測試行視為缺陷。 - 謹慎使用瀏覽器/E2E 測試(Pest 4)。 Pest 4 透過
visit('/')->...->assertSee(...)(基於 Playwright)提供瀏覽器測試功能。將其保留給真正的全端/JS 流程;不要將 HTTP 層的斷言重新實作為緩慢的瀏覽器測試。
好與壞的範例
// tests/Feature/UpdatePostTest.php
use App\Models\Post;
use App\Models\User;
// ❌ 過度模擬:測試的是方法被呼叫,而不是實際發生的結果。
// 即使更新了錯誤的文章或缺少授權,測試仍然會通過。
it('updates a post', function () {
$service = Mockery::mock(App\Services\PostService::class);
$service->shouldReceive('update')->once()->andReturnTrue();
$this->app->instance(App\Services\PostService::class, $service);
$this->put('/posts/1', ['title' => 'New'])->assertOk();
});
// ✅ 針對測試資料庫執行真實程式碼;斷言結果和授權。
use App\Models\Post;
use App\Models\User;
it('lets the owner update their post', function () {
$owner = User::factory()->create();
$post = Post::factory()->for($owner)->create(['title' => 'Old']);
$this->actingAs($owner)
->put("/posts/{$post->id}", ['title' => 'New'])
->assertRedirect();
$this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => 'New']);
});
it('forbids a non-owner from updating the post', function () {
$post = Post::factory()->create(['title' => 'Old']);
$attacker = User::factory()->create();
$this->actingAs($attacker)
->put("/posts/{$post->id}", ['title' => 'Hacked'])
->assertForbidden(); // 403
$this->assertDatabaseHas('posts', ['id' => $post->id, 'title' => 'Old']);
});
it('blocks guests', function () {
$post = Post::factory()->create();
$this->put("/posts/{$post->id}", ['title' => 'New'])
->assertRedirect('/login'); // 或者對於 API 使用 assertUnauthorized()
});
// ❌ 命中真實網路(不穩定、緩慢、可能洩漏金鑰);斷言沒有意義。
it('notifies slack', function () {
$resp = (new App\Services\Slack)->ping('deploy ok');
expect($resp)->toBeArray();
});
// ✅ 偽造你不擁有的邊界;斷言你發送的請求。
use Illuminate\Support\Facades\Http;
it('posts the deploy message to slack', function () {
Http::fake(['hooks.slack.com/*' => Http::response(['ok' => true])]);
(new App\Services\Slack)->ping('deploy ok');
Http::assertSent(fn ($request) =>
$request->url() === 'https://hooks.slack.com/services/T/B/x'
&& $request['text'] === 'deploy ok'
);
});
// ❌ 複製貼上幾乎相同的驗證測試。
it('rejects empty email', function () {
$this->post('/register', ['email' => ''])->assertSessionHasErrors('email');
});
it('rejects bad email', function () {
$this->post('/register', ['email' => 'nope'])->assertSessionHasErrors('email');
});
// ✅ 一個測試,使用資料集驅動。
it('rejects invalid emails', function (string $email) {
$this->post('/register', ['email' => $email])
->assertSessionHasErrors('email');
})->with([
'empty' => '',
'no at-sign' => 'nope',
'no domain' => 'a@',
]);
// ✅ 偽造非同步 + 時間邊界;斷言分派和副作用。
use App\Jobs\SendReminder;
use App\Mail\WelcomeMail;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
it('queues a reminder and welcomes the user on signup', function () {
Queue::fake();
Mail::fake();
$this->freezeTime();
$this->post('/register', [
'name' => 'Ada', 'email' => '[email protected]', 'password' => 'secret-pass',
])->assertRedirect();
Queue::assertPushed(SendReminder::class);
Mail::assertSent(WelcomeMail::class, fn ($m) => $m->hasTo('[email protected]'));
$this->assertDatabaseHas('users', ['email' => '[email protected]']);
});
// tests/Arch.php — ✅ 廉價的全域護欄
arch()->preset()->php(); // 禁止 die/var_dump/dd 風格的除錯輸出 + 已棄用的 PHP 函數
arch()->preset()->security(); // 禁止 eval, extract, unserialize, md5/sha1, 弱隨機性等
arch()->preset()->laravel(); // 強制執行 Laravel 慣例,包括在 config 之外禁止使用 env()
arch('controllers extend the base controller')
->expect('App\Http\Controllers')
->toExtend('App\Http\Controllers\Controller');
arch('models live in the right namespace')
->expect('App\Models')
->toExtend('Illuminate\Database\Eloquent\Model');
如何驗證
在宣稱一個功能及其測試已完成之前,請執行以下操作:
# 1. 整個測試套件必須通過。
./vendor/bin/pest
# 2. 在迭代時僅運行新增/更改的檔案。
./vendor/bin/pest tests/Feature/UpdatePostTest.php
# 3. 強制設定覆蓋率門檻(需要 Xdebug 或 PCOV)。
./vendor/bin/pest --coverage --min=80
# 4. 型別覆蓋率(需要 pestphp/pest-plugin-type-coverage)。
./vendor/bin/pest --type-coverage --min=100
# 5. 程式碼風格保持整潔。
./vendor/bin/pint --test
然後審計你沒有重新引入陷阱:
# 授權覆蓋率:每個受保護的功能測試都應斷言 401/403/404 路徑。
grep -rEn "assertForbidden|assertUnauthorized|assertNotFound|->assertStatus\\((401|403|404)\)" tests/
# 過度模擬的氣味:模擬你自己的 APP 類別是一個紅旗——審查每個匹配項。
grep -rEn "Mockery::|->shouldReceive\(|::partialMock\(|::spy\(" tests/
# 手動插入而非使用工廠——替換為 ->factory()。
grep -rEn "DB::table\(.*\)->insert|new App\\\\Models" tests/
# 外部邊界必須被偽造——確認程式碼呼叫外部服務的地方存在偽造。
grep -rEn "Http::fake|Queue::fake|Bus::fake|Mail::fake|Notification::fake|Storage::fake|Event::fake" tests/
# 必須存在安全性 + php 架構預設(廉價的全域護欄)。
grep -rEn "preset\(\)->(security|php|laravel)\(\)" tests/
在測試中斷言,而不僅僅是在程式碼中:對於每個新端點,確認存在 (a) 快樂路徑結果斷言 (assertDatabaseHas/assertOk/assertJsonPath),(b) 至少一個失敗/驗證斷言,以及 (c) 訪客 + 錯誤使用者的授權斷言。如果缺少任何一個,則該功能尚未完成。
何時可以打破規則
- 在真正的外部接合處模擬你自己的程式碼。 一個圍繞付費第三方 SDK(SMS、支付閘道)的薄包裝器,你無法在 HTTP 層對其進行偽造,是合法的
Mockery目標——你是在隔離他們的依賴項,而不是偽造被測試的行為。 - 對沒有資料庫的純邏輯進行單元測試。 值物件、格式化程式和計算器不需要
RefreshDatabase也不需要工廠——直接實例化它們並expect()結果;這樣更快且正確。 - 當測試從不持久化時,使用
make()而非create()。User::factory()->make()為純邏輯斷言避免了資料庫寫入。 - 對遺留程式碼設定較低的覆蓋率門檻。 設定一個現實的
--min並隨著時間推移逐步提高,而不是阻止所有工作;永遠不要讓「100% 否則不行」成為交付零測試的藉口。 - 對真正由 JS 驅動的流程進行瀏覽器測試(Livewire/Inertia/Vue 互動、檔案選擇器)——在這種情況下,
visit()是正確的工具,而不是過度使用。
參考資料
- Pest — 編寫測試、期望、鉤子:https://pestphp.com/docs/writing-tests
- Pest — 資料集:https://pestphp.com/docs/datasets
- Pest — 架構測試與預設(
security、laravel、toExtend):https://pestphp.com/docs/arch-testing - Pest — 覆蓋率與
--min:https://pestphp.com/docs/coverage - Pest — 型別覆蓋率外掛:https://pestphp.com/docs/type-coverage
- Pest v4 — 瀏覽器測試(
visit()、視覺回歸):https://pestphp.com/docs/browser-testing - Laravel — HTTP 測試(狀態/JSON/重新導向斷言):https://laravel.com/docs/testing
- Laravel — 資料庫測試(
RefreshDatabase、工廠、assertDatabaseHas):https://laravel.com/docs/database-testing - Laravel — 模擬與 Facade 偽造(
Http、Queue、Bus、Mail、Notification、Event、Storage、時間):https://laravel.com/docs/mocking