使用 TDD 進行 Laravel 測試
使用 PHPUnit、Pest、Laravel 工廠和測試輔助工具,對 Laravel 應用程式進行測試驅動開發。
啟用時機
- 撰寫新的 Laravel 應用程式或功能
- 使用 Sanctum 或 Passport 認證實作 API 端點
- 測試 Eloquent 模型、關聯、範圍(scope)和存取器(accessor)
- 為 Laravel 專案設定測試基礎架構
- 撰寫 HTTP 控制器和表單請求的功能測試
- 模擬外部服務(佇列、郵件、通知、HTTP)
Laravel 的 TDD 工作流程
紅-綠-重構循環
// 步驟 1:紅 — 撰寫一個會失敗的測試
public function test_a_product_can_be_created(): void
{
$product = Product::factory()->create(['name' => '測試產品']);
$this->assertDatabaseHas('products', ['name' => '測試產品']);
}
// 步驟 2:綠 — 撰寫遷移、模型和工廠
// 步驟 3:重構 — 在保持測試通過的情況下改進程式碼
設定
PHPUnit 設定
<?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>
基礎 TestCase 設定
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
protected function setUp(): void
{
parent::setUp();
// 只有在測試非 HTTP 例外時才呼叫 $this->withoutExceptionHandling();
// 它會抑制 assertStatus() 等功能。
}
// 輔助方法:認證並回傳使用者
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;
}
}
模型工廠
// database/factories/UserFactory.php
class 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.php
class 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]);
}
}
使用工廠
$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(); // 不持久化
// 搭配關聯
$user = User::factory()->has(Product::factory()->count(3))->create();
// 序列
User::factory()->count(3)->sequence(
['role' => 'admin'], ['role' => 'editor'], ['role' => 'user'],
)->create();
模型測試
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));
}
}
功能 / HTTP 測試
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' => '新產品',
'description' => '描述',
'price' => 2999,
'stock' => 10,
]);
$response->assertRedirect(route('products.index'));
$this->assertDatabaseHas('products', [
'name' => '新產品',
'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 測試
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 產品',
'price' => 4999,
]);
$response->assertCreated();
$response->assertJsonPath('data.name', 'API 產品');
}
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 認證測試
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' => '測試使用者',
'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)與假物件(Fakes)
HTTP 假物件
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();
$order->sendConfirmation();
Mail::assertSent(OrderConfirmation::class, function ($mail) use ($order) {
return $mail->hasTo($order->user->email);
});
通知假物件
Notification::fake();
$user->notify(new WelcomeUser());
Notification::assertSentTo($user, WelcomeUser::class);
佇列假物件
Queue::fake();
ProcessImage::dispatch($product);
Queue::assertPushed(ProcessImage::class, function ($job) use ($product) {
return $job->product->id === $product->id;
});
儲存空間假物件
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();
$order->markAsShipped();
Event::assertDispatched(OrderShipped::class, function ($event) use ($order) {
return $event->order->id === $order->id;
});
Artisan 命令測試
public function test_it_sends_newsletters(): void
{
Mail::fake();
User::factory()->count(5)->create(['subscribed' => true]);
$this->artisan('newsletter:send')
->expectsOutput('正在發送電子報給 5 位訂閱者...')
->assertExitCode(0);
Mail::assertSent(NewsletterMail::class, 5);
}
public function test_it_handles_no_subscribers(): void
{
$this->artisan('newsletter:send')
->expectsOutput('找不到任何訂閱者。')
->assertExitCode(0);
}
授權測試
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' => '已更新'])
->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' => '被駭'])
->assertForbidden();
}
程式碼覆蓋率
執行包含覆蓋率的測試:
php artisan test --coverage
# 或
composer test -- --coverage-html coverage-report
在 phpunit.xml 中忽略目錄
<coverage ignoreDeprecatedCodeUnits="true">
<report>
<html outputDirectory="coverage-report" lowUpperBound="50" highLowerBound="90"/>
</report>
</coverage>
使用 Pest
// tests/Feature/ProductTest.php
uses(RefreshDatabase::class);
test('使用者可以建立產品', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->post('/products', [
'name' => 'Pest 產品',
'price' => 1999,
]);
$response->assertRedirect();
$this->assertDatabaseCount('products', 1);
});
test('訪客無法建立產品', function () {
$this->post('/products', ['name' => 'Hack'])->assertRedirect(route('login'));
})->only();
最佳實務
- 每個測試一個斷言 — 將測試焦點集中於單一預期行為
- 使用具描述性的測試名稱 — 例如
test_guest_cannot_view_admin_dashboard - 優先使用 Factory 而非 Seeder — Factory 更具靈活性且可組合
- 使用
RefreshDatabase確保隔離 — 每個測試後清理資料庫 - 使用 Fake 而非外部 API — 避免測試中的外部依賴
- 必要時使用
withoutExceptionHandling()進行除錯 — 取得實際的例外資訊 - 測試邊界案例 — 空結果、權限不足、驗證失敗
常見問題排除
- SQLite 記憶體資料庫上的 JSON 欄位:確保遷移中呼叫
->json()而非->text() assertDatabaseHas與模型存取器:存取器會修改屬性值;請斷言資料庫原始值- Sanctum 令牌作用域:在
tests/Feature中的測試請確保定義了正確的abilities - 使用 SQLite 測試 UUID 主鍵:需要
use \Illuminate\Support\Str;並在模型中設定$incrementing = false