在〈Laravel Test 實戰:與框架結合的測試技巧 〉那篇文章裡,我們證明了「光靠 Laravel 內建工具」,就能用 assertDatabaseHas()
、Http::fake()
、Mail::fake()
等方式覆蓋多數業務流程,幫團隊跨出測試第一步。然而,當專案走向多層 Service、鏈式 SDK 與第三方套件時,你很快就會遇到另一層瓶頸——要連真實資料庫、呼叫外部 API,甚至真的寄出 Email,測試跑得慢又容易失敗。
真正的難題不是技術,而是 缺少一套成熟且可精準驗證的替身(Mock)機制。因此,本篇以 Laravel 為基準,帶你用更進階的 Mockery 補足內建 fake 的不足;除了講清楚原理,也準備了 4 組可直接貼上的模擬範例,讓你在不碰真實依賴的情況下,依舊能驗證呼叫次數、參數內容,甚至攔截鏈式方法,把測試覆蓋率一路推進到核心商業邏輯。
為什麼 Laravel 仍需要 Mockery?
Laravel 內建Http::fake()
、Mail::fake()
,但在以下情境仍力有未逮:- 鏈式 SDK:
$gateway->auth()->charge()
- 複雜邏輯依賴: Repository、Service Layer
- 跨專案共用套件: 純 PHP 套件無法用 Laravel fake
- 呼叫次數、引數驗證: 需要精準斷言「呼叫幾次」「帶什麼參數」
Mockery 已被列入 Laravel 的 require-dev
,並且 Tests\TestCase
會在 tearDown()
自動呼叫 Mockery::close()
,與框架無縫結合。
環境確認(Laravel 8 以上,預設已內建)
composer show mockery/mockery
若能看到版本號(例如 1.6.x
),表示框架已內建,不需額外安裝; 只有在 舊版專案 或 純 PHP 套件 才需要手動 composer require mockery/mockery
並自行設定 listener。
範例 1:Mock Mailer 發送電子郵件
<?php
namespace Tests\Feature;
use App\Services\InvoiceService;
use App\Services\MailerInterface;
use Mockery as m;
use Tests\TestCase;
class InvoiceTest extends TestCase
{
public function test_receipt_mail_is_sent_on_payment()
{
// 準備 Mock 物件
$mailer = m::mock(MailerInterface::class);
// 定義期望行為
$mailer->shouldReceive('sendReceipt')
->once()
->withArgs(function ($user, $amount) {
// 驗證參數是否符合預期
return $user->id === 1 && $amount === 999;
})
->andReturnTrue();
// 將 Mock 注入到測試目標中
$this->app->instance(MailerInterface::class, $mailer);
// 執行測試
$service = $this->app->make(InvoiceService::class);
$user = \App\Models\User::find(1) ?? \App\Models\User::factory()->create();
// 驗證結果
$this->assertTrue($service->processPayment($user, 999));
}
}
本例展示了:
- 精確控制方法呼叫次數(
once()
) - 驗證傳入參數(
withArgs()
) - 自定義回傳值(
andReturnTrue()
)
範例 2:驗證外部支付閘道 API 呼叫
<?php
namespace Tests\Unit;
use App\Services\PaymentService;
use App\Gateways\PaymentGatewayInterface;
use Mockery as m;
use Tests\TestCase;
class PaymentServiceTest extends TestCase
{
public function test_payment_gateway_receives_correct_arguments()
{
// 建立 Mock 物件
$gateway = m::mock(PaymentGatewayInterface::class);
// 設定精確的參數驗證
$gateway->shouldReceive('charge')
->withArgs(function (int $amount, string $currency, array $options) {
// 驗證金額、幣別和選項
return $amount === 1000 &&
$currency === 'TWD' &&
isset($options['description']) &&
$options['capture'] === true;
})
->once()
->andReturn([
'transaction_id' => 'tx_12345',
'status' => 'success',
'created_at' => now()->toIso8601String()
]);
$this->app->instance(PaymentGatewayInterface::class, $gateway);
// 執行測試目標方法
$service = $this->app->make(PaymentService::class);
$result = $service->processCharge(1000, 'TWD', '商品購買');
// 驗證結果
$this->assertEquals('success', $result['status']);
$this->assertEquals('tx_12345', $result['transaction_id']);
}
}
本例展示了:
- 複雜參數驗證(數字、字串、陣列)
- 模擬真實的 API 回應結構
- 驗證服務層如何正確處理外部依賴
範例 3:模擬鏈式 SDK 調用
<?php
namespace Tests、Unit;
use App\Services\NotificationService;
use App\Clients\FirebaseClient;
use Mockery as m;
use Tests\TestCase;
class NotificationServiceTest extends TestCase
{
public function test_firebase_push_notification_chain()
{
// 建立鏈式 Mock
$firebaseClient = m::mock(FirebaseClient::class);
// 設定鏈式呼叫的期望
$firebaseClient->shouldReceive('connect->project->messaging->send')
->once()
->withArgs(function (array $notification) {
return $notification['topic'] === 'new_updates' &&
!empty($notification['title']) &&
!empty($notification['body']);
})
->andReturn([
'message_id' => 'projects/myapp/messages/msg_123',
'success' => true
]);
$this->app->instance(FirebaseClient::class, $firebaseClient);
// 執行被測試方法
$service = $this->app->make(NotificationService::class);
$result = $service->sendPushNotification(
'new_updates',
'系統更新通知',
'您的應用已更新至最新版本'
);
// 驗證結果
$this->assertTrue($result);
}
}
本例展示:
- 鏈式方法調用的模擬(
connect->project->messaging->send
) - 複雜結構的參數驗證
- 模擬成功響應場景
範例 4:部分替身(Partial Mock)和 Spy 監聽
<?php
namespace Tests\Feature;
use App\Services\HeartbeatService;
use App\Repositories\UserRepository;
use Mockery as m;
use Mockery\MockInterface;
use Tests\TestCase;
class HeartbeatTest extends TestCase
{
public function test_heartbeat_updates_last_seen_and_cache()
{
// 取得認證使用者
$user = \App\Models\User::factory()->create();
// 建立部分替身
$heartbeatService = $this->partialMock(HeartbeatService::class, function (MockInterface $mock) {
// 只模擬 updateCache 方法,其他方法保持原始實現
$mock->shouldReceive('updateCache')
->once()
->withArgs(function ($userId, $timestamp) {
return is_numeric($userId) && $timestamp instanceof \DateTime;
})
->andReturnTrue();
});
// 同時監聽 UserRepository(作為 Spy)
$userRepo = m::spy(UserRepository::class);
$this->app->instance(UserRepository::class, $userRepo);
// 執行測試 - 呼叫 API
$response = $this->actingAs($user)
->getJson('/api/heartbeat');
// 驗證 API 回應正確
$response->assertStatus(200)
->assertJsonStructure([
'status',
'message',
'last_seen_at'
]);
// 使用 Spy 驗證方法被調用,不需事先設定期望
$userRepo->shouldHaveReceived('updateLastSeen')
->once()
->with(m::type('int'), m::type('string'));
}
}
本例展示:
- 部分替身(
partialMock
)保留大部分原始邏輯 - Spy 模式監聽方法調用(不需事先設定)
- 與 Laravel 測試輔助方法結合使用
常見錯誤與快速排解
- 方法拼錯 / 參數不符 →
Received … but expectations were not met
- 檢查
shouldReceive
與實際呼叫是否一致。
- 檢查
- 忘了關閉 Mock → 測試間干擾
- Laravel 預設
Tests\TestCase
已在tearDown()
自動Mockery::close()
。
- Laravel 預設
- 過度 Mock 造成測試難維護
- 只 Mock「跨界」依賴(I/O、第三方),核心邏輯依舊走真實物件。
Mockery × Laravel,測試不再被依賴綁架
- 環境已內建:Laravel 預裝 Mockery,零設定即可使用。
- 基本語法:
shouldReceive
+once/times
+withArgs
。 - 鏈式、Partial、Spy:對應實務各種複雜情境。
現在就挑一支服務,把真實依賴抽成介面並替換為 Mockery,看測試速度與可靠度能提升多少;相信你會在下一次 CI 跑綠燈的那一刻,徹底感受到「假物件帶來的真實快感」。