Laravel Tdd

affaan-m/ECC/pi/core/skills/laravel-tdd

作者 affaan-mef648e01899ba3e8dc6371642deaaf64b4477775無授權條款275K 個星標收錄於 2026年10月9日更新於 2026年10月9日儲存庫4 天前更新

Laravel testing strategies with PHPUnit, Pest, model factories, HTTP tests, Sanctum authentication testing, mocking, and coverage. Use when writing Laravel tests with PHPUnit or Pest, or driving a Laravel feature test-first.

AI 產生的概覽

指導 Laravel 應用程式使用 PHPUnit、Pest、工廠、HTTP 測試與偽造進行測試驅動開發。

功能
此技能提供在測試驅動 red-green-refactor 循環中撰寫 Laravel 測試的說明與程式碼範例。內容涵蓋 PHPUnit 設定、基礎 TestCase 輔助方法、模型工廠、Eloquent 模型測試、功能與 HTTP 控制器測試、JSON API 測試、Sanctum 身分驗證測試,以及 HTTP、郵件、通知、佇列、儲存與事件的偽造。也包含 Artisan 指令測試、授權測試、Pest 功能測試範例、覆蓋率指令與目標,以及測試最佳實務。
適用情境
適用於使用 PHPUnit 或 Pest 撰寫或建置 Laravel 測試,或以測試優先方式開發 Laravel 功能時。適合使用 Sanctum 或 Passport 的 API 端點、Eloquent 模型與關聯測試,以及模擬外部服務。
執行需求
需要一個包含 PHPUnit 或 Pest 及 Laravel 測試輔助工具的 Laravel 專案;範例使用 SQLite 記憶體資料庫以及 array 快取、郵件、佇列與工作階段驅動。不附帶指令碼,僅為說明文件。

Laravel Testing with TDD

Test-driven development for Laravel applications using PHPUnit, Pest, Laravel factories, and testing helpers.

When to Activate

  • Writing new Laravel applications or features
  • Implementing API endpoints with Sanctum or Passport authentication
  • Testing Eloquent models, relationships, scopes, and accessors
  • Setting up testing infrastructure for Laravel projects
  • Writing feature tests for HTTP controllers and form requests
  • Mocking external services (queues, mail, notifications, HTTP)

TDD Workflow for Laravel

Red-Green-Refactor Cycle

php
// Step 1: RED — Write a failing testpublic function test_a_product_can_be_created(): void{    $product = Product::factory()->create(['name' => 'Test Product']);    $this->assertDatabaseHas('products', ['name' => 'Test Product']);}
// Step 2: GREEN — Write the migration, model, and factory// Step 3: REFACTOR — Improve while keeping tests green

Setup

PHPUnit Configuration

xml
<?xml version="1.0" encoding="UTF-8"?><phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"         bootstrap="vendor/autoload.php"         colors="true">    <testsuites>        <testsuite name="Unit">            <directory suffix="Test.php">tests/Unit</directory>        </testsuite>        <testsuite name="Feature">            <directory suffix="Test.php">tests/Feature</directory>        </testsuite>    </testsuites>    <php>        <env name="APP_ENV" value="testing"/>        <env name="BCRYPT_ROUNDS" value="4"/>        <env name="CACHE_STORE" value="array"/>        <env name="DB_CONNECTION" value="sqlite"/>        <env name="DB_DATABASE" value=":memory:"/>        <env name="MAIL_MAILER" value="array"/>        <env name="QUEUE_CONNECTION" value="sync"/>        <env name="SESSION_DRIVER" value="array"/>    </php></phpunit>

Base TestCase Setup

php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase{    protected function setUp(): void    {        parent::setUp();        // Call $this->withoutExceptionHandling() only in tests that        // test non-HTTP exceptions; it suppresses assertStatus() etc.    }
    // Helper: Authenticate and return user    protected function actingAsUser(): mixed    {        $user = \App\Models\User::factory()->create();        $this->actingAs($user);        return $user;    }
    protected function actingAsAdmin(): mixed    {        $admin = \App\Models\User::factory()->admin()->create();        $this->actingAs($admin);        return $admin;    }}

Model Factories

php
// database/factories/UserFactory.phpclass UserFactory extends Factory{    protected static ?string $password = null;
    public function definition(): array    {        return [            'name' => fake()->name(),            'email' => fake()->unique()->safeEmail(),            'email_verified_at' => now(),            'password' => static::$password ??= Hash::make('password'),            'remember_token' => Str::random(10),            'role' => 'user',        ];    }
    public function admin(): static    {        return $this->state(fn (array $attributes) => ['role' => 'admin']);    }
    public function unverified(): static    {        return $this->state(fn (array $attributes) => ['email_verified_at' => null]);    }}
// database/factories/ProductFactory.phpclass ProductFactory extends Factory{    public function definition(): array    {        return [            'name' => fake()->unique()->words(3, true),            'slug' => fn (array $attrs) => Str::slug($attrs['name']),            'description' => fake()->paragraph(),            'price' => fake()->numberBetween(100, 100000),            'stock' => fake()->numberBetween(0, 100),            'is_active' => true,            'user_id' => UserFactory::new(),        ];    }
    public function outOfStock(): static    {        return $this->state(fn (array $attributes) => ['stock' => 0]);    }}

Using Factories

php
$user = User::factory()->create();$admin = User::factory()->admin()->create();$product = Product::factory()->create(['user_id' => $user->id]);$products = Product::factory()->count(10)->create();$draft = Product::factory()->make(); // Not persisted
// With relationships$user = User::factory()->has(Product::factory()->count(3))->create();
// SequencesUser::factory()->count(3)->sequence(    ['role' => 'admin'], ['role' => 'editor'], ['role' => 'user'],)->create();

Model Testing

php
namespace Tests\Unit\Models;
use App\Models\User;use App\Models\Product;use Illuminate\Foundation\Testing\RefreshDatabase;use Tests\TestCase;
class UserTest extends TestCase{    use RefreshDatabase;
    public function test_it_hides_sensitive_attributes(): void    {        $user = User::factory()->create();        $this->assertArrayNotHasKey('password', $user->toArray());    }
    public function test_admin_scope_returns_only_admins(): void    {        User::factory()->admin()->create();        User::factory()->count(3)->create();
        $this->assertCount(1, User::admin()->get());    }}
class ProductTest extends TestCase{    use RefreshDatabase;
    public function test_active_scope_filters_correctly(): void    {        Product::factory()->count(3)->create(['is_active' => true]);        Product::factory()->count(2)->create(['is_active' => false]);
        $this->assertCount(3, Product::active()->get());    }
    public function test_it_belongs_to_a_user(): void    {        $user = User::factory()->create();        $product = Product::factory()->create(['user_id' => $user->id]);
        $this->assertTrue($product->user->is($user));    }}

Feature / HTTP Testing

php
namespace Tests\Feature\Http\Controllers;
use App\Models\Product;use App\Models\User;use Illuminate\Foundation\Testing\RefreshDatabase;use Tests\TestCase;
class ProductControllerTest extends TestCase{    use RefreshDatabase;
    public function test_guests_are_redirected_to_login(): void    {        $this->get(route('products.create'))->assertRedirect(route('login'));    }
    public function test_it_stores_a_new_product(): void    {        $user = User::factory()->create();        $this->actingAs($user);
        $response = $this->post(route('products.store'), [            'name' => 'New Product',            'description' => 'Description',            'price' => 2999,            'stock' => 10,        ]);
        $response->assertRedirect(route('products.index'));        $this->assertDatabaseHas('products', [            'name' => 'New Product',            'user_id' => $user->id,        ]);    }
    public function test_it_validates_required_fields(): void    {        $this->actingAs(User::factory()->create());        $this->post(route('products.store'), [])            ->assertSessionHasErrors(['name', 'price']);    }
    public function test_users_cannot_modify_others_products(): void    {        $owner = User::factory()->create();        $attacker = User::factory()->create();        $product = Product::factory()->create(['user_id' => $owner->id]);
        $this->actingAs($attacker)            ->delete(route('products.destroy', $product))            ->assertForbidden();    }}

JSON API Testing

php
namespace Tests\Feature\Http\Controllers\Api;
use App\Models\Product;use App\Models\User;use Illuminate\Foundation\Testing\RefreshDatabase;use Tests\TestCase;
class ProductApiTest extends TestCase{    use RefreshDatabase;
    public function test_unauthenticated_requests_are_rejected(): void    {        $this->getJson('/api/products')->assertUnauthorized();    }
    public function test_it_lists_paginated_products(): void    {        $user = User::factory()->create();        Product::factory()->count(5)->create(['user_id' => $user->id]);
        $response = $this->actingAs($user)->getJson('/api/products');
        $response->assertOk();        $response->assertJsonCount(5, 'data');        $response->assertJsonStructure([            'data' => [['id', 'name', 'price']],            'meta' => ['current_page', 'last_page', 'total'],        ]);    }
    public function test_it_creates_a_product(): void    {        $user = User::factory()->create();
        $response = $this->actingAs($user)->postJson('/api/products', [            'name' => 'API Product',            'price' => 4999,        ]);
        $response->assertCreated();        $response->assertJsonPath('data.name', 'API Product');    }
    public function test_users_cannot_delete_others_products(): void    {        $owner = User::factory()->create();        $attacker = User::factory()->create();        $product = Product::factory()->create(['user_id' => $owner->id]);
        $this->actingAs($attacker)            ->deleteJson("/api/products/{$product->id}")            ->assertForbidden();    }}

Sanctum API Auth Testing

php
namespace Tests\Feature\Http\Controllers\Api;
use App\Models\User;use Illuminate\Foundation\Testing\RefreshDatabase;use Illuminate\Support\Facades\Hash;use Tests\TestCase;
class AuthControllerTest extends TestCase{    use RefreshDatabase;
    public function test_users_can_register(): void    {        $response = $this->postJson('/api/register', [            'name' => 'Test User',            'email' => '[email protected]',            'password' => 'Password123!',            'password_confirmation' => 'Password123!',        ]);
        $response->assertCreated();        $response->assertJsonStructure(['data' => ['user', 'token']]);    }
    public function test_users_can_login(): void    {        User::factory()->create([            'email' => '[email protected]',            'password' => Hash::make('Password123!'),        ]);
        $response = $this->postJson('/api/login', [            'email' => '[email protected]',            'password' => 'Password123!',        ]);
        $response->assertOk();        $response->assertJsonStructure(['data' => ['token']]);    }
    public function test_users_cannot_login_with_wrong_password(): void    {        User::factory()->create(['email' => '[email protected]']);
        $this->postJson('/api/login', [            'email' => '[email protected]',            'password' => 'wrong',        ])->assertUnprocessable();    }
    public function test_token_bearer_authenticates_requests(): void    {        $user = User::factory()->create();        $token = $user->createToken('test')->plainTextToken;
        $this->withToken($token)            ->getJson('/api/user')            ->assertOk()            ->assertJsonPath('data.email', $user->email);    }}

Mocking and Fakes

HTTP Fake

php
use Illuminate\Support\Facades\Http;
public function test_it_handles_successful_payment(): void{    Http::fake([        'api.stripe.com/*' => Http::response(['id' => 'pi_123', 'status' => 'succeeded'], 200),    ]);
    $result = (new PaymentService())->charge(2999);    $this->assertTrue($result->success);}
public function test_it_handles_gateway_failure(): void{    Http::fake([        'api.stripe.com/*' => Http::response(['error' => 'card_declined'], 402),    ]);
    $this->expectException(PaymentFailedException::class);    (new PaymentService())->charge(2999);}
public function test_it_retries_on_timeout(): void{    Http::fake([        'api.stripe.com/*' => Http::sequence()            ->pushStatus(408)            ->pushStatus(200),    ]);
    $this->assertTrue((new PaymentService())->charge(2999)->success);}

Mail Fake

php
Mail::fake();
$order->sendConfirmation();
Mail::assertSent(OrderConfirmation::class, function ($mail) use ($order) {    return $mail->hasTo($order->user->email);});

Notification Fake

php
Notification::fake();
$user->notify(new WelcomeUser());
Notification::assertSentTo($user, WelcomeUser::class);

Queue Fake

php
Queue::fake();
ProcessImage::dispatch($product);
Queue::assertPushed(ProcessImage::class, function ($job) use ($product) {    return $job->product->id === $product->id;});

Storage Fake

php
Storage::fake('public');
$file = UploadedFile::fake()->image('photo.jpg', 200, 200);
$response = $this->actingAs($user)->post('/avatar', [    'avatar' => $file,]);
$response->assertSessionHasNoErrors();Storage::disk('public')->assertExists('avatars/' . $file->hashName());

Event Fake

php
Event::fake();
$order->markAsShipped();
Event::assertDispatched(OrderShipped::class, function ($event) use ($order) {    return $event->order->id === $order->id;});

Artisan Command Tests

php
public function test_it_sends_newsletters(): void{    Mail::fake();    User::factory()->count(5)->create(['subscribed' => true]);
    $this->artisan('newsletter:send')        ->expectsOutput('Sending newsletter to 5 subscribers...')        ->assertExitCode(0);
    Mail::assertSent(NewsletterMail::class, 5);}
public function test_it_handles_no_subscribers(): void{    $this->artisan('newsletter:send')        ->expectsOutput('No subscribers found.')        ->assertExitCode(0);}

Authorization Tests

php
public function test_users_can_update_own_posts(): void{    $user = User::factory()->create();    $post = Post::factory()->create(['user_id' => $user->id]);
    $this->actingAs($user)        ->put(route('posts.update', $post), ['title' => 'Updated'])        ->assertRedirect();}
public function test_users_cannot_update_others_posts(): void{    $post = Post::factory()->create();    $this->actingAs(User::factory()->create())        ->put(route('posts.update', $post), ['title' => 'Hacked'])        ->assertForbidden();}
public function test_gate_before_grants_super_admin_full_access(): void{    $super = User::factory()->create(['role' => 'super-admin']);    $post = Post::factory()->create();
    $this->actingAs($super)        ->delete(route('posts.destroy', $post))        ->assertRedirect();
    $this->assertSoftDeleted($post);}

Pest Feature Tests

php
<?php
use App\Models\Product;use App\Models\User;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {    $this->user = User::factory()->create();    $this->actingAs($this->user);});
it('lists products', function () {    Product::factory()->count(3)->create(['user_id' => $this->user->id]);
    $this->get(route('products.index'))        ->assertOk()        ->assertViewHas('products');});
it('creates a product with valid data', function () {    $this->post(route('products.store'), [        'name' => 'Test Product', 'price' => 1999,    ])->assertRedirect();
    $this->assertDatabaseHas('products', ['name' => 'Test Product']);});
it('fails validation without required fields', function () {    $this->post(route('products.store'), [])        ->assertSessionHasErrors(['name', 'price']);});
it('authorizes updates', function () {    $other = User::factory()->create();    $product = Product::factory()->create(['user_id' => $other->id]);
    $this->put(route('products.update', $product), ['name' => 'Hacked'])        ->assertForbidden();});

Coverage

bash
# PHPUnit (use clover output for CI threshold checks)vendor/bin/phpunit --coverage-html coverage --coverage-clover clover.xml
# Pest (built-in threshold support)vendor/bin/pest --coverage --min=80

Coverage Goals

ComponentTarget
Models95%+
Actions/Services90%+
Form Requests90%+
Controllers85%+
Policies95%+
Overall80%+

Testing Best Practices

DO

  • Use factories over manual create() calls
  • One logical assertion per test
  • Descriptive names: test_guests_cannot_create_products
  • Test edge cases and authorization boundaries
  • Mock external services with Http::fake(), Mail::fake()
  • Use RefreshDatabase for clean state

DON'T

  • Don't test Laravel internals (trust the framework)
  • Don't make tests dependent on each other
  • Don't over-mock — mock only service boundaries
  • Don't test private methods — test through the public interface
  • Don't couple tests to HTML structure

Quick Reference

PatternUsage
RefreshDatabaseReset database between tests
$this->actingAs($user)Authenticate as user
$this->withToken($token)Bearer token auth for APIs
Model::factory()->create()Create model with factory
Model::factory()->count(5)->create()Create multiple records
Http::fake([...])Mock HTTP calls
Mail::fake()Trap sent mail
Notification::fake()Trap sent notifications
Queue::fake()Trap queued jobs
Event::fake()Trap dispatched events
Storage::fake('public')Trap file operations
assertDatabaseHasAssert DB row exists
assertSoftDeletedAssert soft-delete
assertSessionHasErrorsAssert validation errors
assertForbiddenAssert 403 status

Related Skills

  • laravel-patterns — Laravel architecture, Eloquent, routing, and API patterns
  • laravel-security — Laravel authentication, authorization, and secure coding
  • tdd-workflow — The repo-wide RED -> GREEN -> REFACTOR loop
  • backend-patterns — General backend API and database patterns

來源與署名

來源:affaan-m/ECC位於pi/core/skills/laravel-tdd提交ef648e0

授權條款: 無授權條款

內容歸原作者所有。SourceWeft 從公開儲存庫中收錄這些內容。

檢舉或申請下架