Community寫作與編輯github.com

shaxzodbek-uzb/laravel-guardrails

🛡️ 為 AI 輔助的 Laravel 開發提供的生產環境護欄技能 — 在 AI 代理部署前,阻止跨租戶資料洩漏、N+1 查詢及破壞性遷移。支援 Claude Code、Cursor 及超過 40 個代理。

laravel-guardrails 是什麼?

這項技能專為使用 AI 代理(如 Claude Code、Cursor、Codex 等 40 多個平台)進行 Laravel 開發的工程師設計,提供一個強制性的測試紀律框架,以確保生產環境的穩定性與安全性。它解決了兩個常見且代價高昂的失敗模式:一是缺少測試或僅有快樂路徑測試,導致生產環境中出現授權漏洞(如跨租戶資料洩漏)與驗證缺口;二是脆弱的過度模擬測試,使用 Mockery 模擬自己的服務和模型,實際上並未驗證真正的行為,反而在重構時提供虛假信心。該技能的核心是在開發過程中即時編寫 Pest 測試,透過行為驅動的斷言(例如 assertDatabaseHas、assertStatus)而非檢查內部實作細節,來驗證真實結果。它特別強調測試快樂路徑、驗證/失敗路徑以及完整的授權情境(訪客、其他使用者、資源不存在),並透過架構測試(arch testing)提供廉價的全域防護。此技能將安全性、隔離測試和可維護性放在首位,確保 AI 生成的 Laravel 程式碼在生產環境中安全可靠。

相容平台✓Claude Code~Codex CLI✓Cursor
npx skills add shaxzodbek-uzb/laravel-guardrails

Installed? Explore more 寫作與編輯 skills: steipete/notion, langchain-ai/langchain, bytedance/podcast-generation · View all 6 →

在你喜歡的 AI 中提問

開啟一個已預先載入此 Agent Skill 的新對話。

說明文件

Pest 測試紀律

功能在未經測試的情況下上線——或者使用過度模擬、斷言實作細節的「測試」——會在生產環境中默默崩潰,並在每次重構時腐化。這項技能迫使你編寫 Pest 3/4 測試,證明的是行為、涵蓋失敗路徑與授權,並且能夠在重構後存活下來。

陷阱

兩種失敗模式,都代價高昂:

  1. 沒有測試 / 僅有快樂路徑測試。 功能在示範時正常運作,然後在生產環境中,訪客點擊了端點、某個租戶讀取了另一個租戶的資料、驗證漏洞讓無效資料進入,或者 404 變成了 500。授權漏洞(從未斷言的 403/404 路徑)正是資料洩漏進入生產環境的方式。
  2. 脆弱、過度模擬的測試。 你使用 shouldReceive('save')->once() 模擬自己的 UserRepository/服務,因此即使真實查詢是錯的,測試也會通過,並且只要有人重新命名方法,它就會崩潰。測試套件顯示綠燈,提供虛假信心,並在下次重構時因挫敗感而被刪除——讓你回到模式 1。

無論哪種情況,解決方案都是同樣的紀律:偽造你不擁有的邊界,執行你擁有的程式碼,並斷言可觀察的結果(資料庫行、HTTP 狀態、分派的工作/郵件)——包括失敗和授權結果。

規則

  1. 與功能一起編寫測試,而不是「稍後」。 控制器/工作/指令/策略的變更在擁有對應的 Pest 測試之前不算完成。永遠不要將沒有對應測試斷言其行為的功能報告為已完成。
  2. 使用 Pest 慣用語法。 使用 it('...') 或 test('...') 定義案例;使用流暢的 expect(...) 進行斷言;使用 beforeEach() 共享設定。除非檔案已經使用該風格,否則不要編寫 PHPUnit class FooTest extends TestCase。
  3. 選擇正確的類型。 HTTP/端到端行為 → 功能測試(啟動框架,透過 $this->get/post/... 點擊路由)。沒有框架/資料庫的純邏輯 → 單元測試。不確定時,預設使用功能測試——它能捕捉更多問題。
  4. 使用 trait 重置資料庫,透過 uses()。 在 tests/Pest.php 中為整個 Feature 目錄套用 RefreshDatabase(包裝在交易中)或 LazilyRefreshDatabase(僅在測試觸及資料庫時才執行遷移),或者在檔案頂部使用 uses(RefreshDatabase::class)。永遠不要讓資料庫測試依賴於遺留狀態。
  5. 使用工廠建立資料,絕不要手動插入。 使用 User::factory()->create() / ->make() 和關聯工廠。測試中手動建構的 DB::table()->insert([...]) 或 new Model([...])->save() 很脆弱,會跳過型別轉換/預設值——禁止使用。
  6. 使用資料集去重,而不是複製貼上。 當同一個斷言在多個輸入上執行時(有效/無效的請求負載、角色矩陣),使用 ->with([...])(內聯或 tests/Datasets/ 中的命名資料集),而不是 N 個幾乎相同的測試。
  7. 偽造你不擁有的邊界。 對於任何外部或非同步的內容,請在動作之前使用 Facade 偽造:Http::fake()、Queue::fake()、Bus::fake()、Mail::fake()、Notification::fake()、Event::fake()、Storage::fake()。對於時間,使用 $this->travel(...)、$this->travelTo(...) 或 $this->freezeTime()——永遠不要針對未凍結的 now() 進行斷言。
  8. 不要將你自己的模型/服務模擬到毫無意義。 避免對被測試的程式碼使用 Mockery::mock(MyService::class)->shouldReceive(...) 和 Model::shouldReceive(...)。讓真實程式碼針對測試資料庫和偽造的邊界執行。僅在一個你沒有在執行的真正接合處(例如,一個緩慢的第三方 SDK 包裝器)模擬你自己的類別。
  9. 斷言結果,而不是內部實作。 優先使用 assertDatabaseHas / assertDatabaseMissing / assertModelExists、assertStatus / assertOk / assertCreated、assertJson / assertJsonPath、assertRedirect、Mail::assertSent、Notification::assertSentTo、Queue::assertPushed、Bus::assertDispatched、Storage::disk()->assertExists。不要將「方法 X 被呼叫一次」當作行為的代理進行斷言。
  10. 始終測試失敗路徑和授權。 每個端點/動作至少需要:快樂路徑、驗證/失敗路徑以及授權路徑——訪客 → 401/重新導向、錯誤的使用者/角色 → 403、缺少/不存在的資源 → 404。對於任何安全敏感的功能,添加一個明確的「其他使用者無法存取/修改此項」的測試。這是任何涉及所有權或租戶的項目的必要條件。
  11. 添加架構測試作為廉價的全域護欄。 保留一個 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')。
  12. 每個測試一個行為,使用描述性名稱。 it('blocks guests from the dashboard'),而不是 it('works')。如果測試名稱需要「和」,請將其拆分。
  13. 在 CI 中設定覆蓋率門檻。 執行 ./vendor/bin/pest --coverage --min=NN 來強制設定最低覆蓋率,以及 ./vendor/bin/pest --type-coverage(需要 type-coverage 外掛)來強制型別簽名。將新的未測試行視為缺陷。
  14. 謹慎使用瀏覽器/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() 是正確的工具,而不是過度使用。

參考資料

相關技能