偽對象模式:測試難以構建依賴時的利器
偽對象 在單元測試中模擬真實物件行為:提供預設回應、追蹤方法呼叫、驗證呼叫順序。SimpleTest、PHPUnit、Mockery 等框架皆內建支援。
核心概念
| 類型 |
用途 |
特點 |
| Dummy |
佔位參數 |
不被實際使用 |
| Stub |
提供預設回應 |
不驗證行為 |
| Spy |
記錄呼叫資訊 |
事後驗證 |
| Mock |
預設期望 + 驗證 |
行為驗證核心 |
| Fake |
簡化實作 |
如記憶體資料庫 |
PHPUnit 內建 Mock 實作
<?php
use PHPUnit\Framework\TestCase;
// 被測類別:依賴外部 API
class PaymentService {
public function __construct(private PaymentGateway $gateway) {}
public function charge(float $amount, string $currency): bool {
return $this->gateway->charge($amount, $currency);
}
}
// 介面
interface PaymentGateway {
public function charge(float $amount, string $currency): bool;
}
// 測試類別
class PaymentServiceTest extends TestCase {
public function testChargeSuccess() {
// 1. 建立 Mock
$mockGateway = $this->createMock(PaymentGateway::class);
// 2. 設定期望:呼叫 charge(100, 'USD') 回傳 true
$mockGateway->expects($this->once())
->method('charge')
->with($this->equalTo(100.0), $this->equalTo('USD'))
->willReturn(true);
// 3. 注入測試對象
$service = new PaymentService($mockGateway);
// 4. 執行並驗證
$result = $service->charge(100, 'USD');
$this->assertTrue($result);
}
public function testChargeFailure() {
$mockGateway = $this->createMock(PaymentGateway::class);
$mockGateway->expects($this->once())
->method('charge')
->willReturn(false);
$service = new PaymentService($mockGateway);
$this->assertFalse($service->charge(50, 'EUR'));
}
public function testChargeException() {
$mockGateway = $this->createMock(PaymentGateway::class);
$mockGateway->expects($this->once())
->method('charge')
->willThrowException(new GatewayException('網路錯誤'));
$service = new PaymentService($mockGateway);
$this->expectException(GatewayException::class);
$service->charge(100, 'USD');
}
}
?>
Mockery:更流暢的 API (需安裝)
<?php
// composer require mockery/mockery
use Mockery as m;
class PaymentServiceMockeryTest extends TestCase {
public function tearDown(): void {
m::close(); // 清理容器
}
public function testChargeWithMockery() {
$gateway = m::mock(PaymentGateway::class);
$gateway->shouldReceive('charge')
->once()
->with(100, 'USD')
->andReturn(true);
$service = new PaymentService($gateway);
$this->assertTrue($service->charge(100, 'USD'));
}
public function testMultipleCalls() {
$gateway = m::mock(PaymentGateway::class);
$gateway->shouldReceive('charge')
->times(3) // 或 atLeast()->once()->atMost()->times(5)
->withArgs([100, 'USD']) // 部分驗證
->andReturn(true, false, true); // 連續回傳不同值
$service = new PaymentService($gateway);
$this->assertTrue($service->charge(100, 'USD'));
$this->assertFalse($service->charge(100, 'USD'));
$this->assertTrue($service->charge(100, 'USD'));
}
}
?>
進階:部分 Mock (Spy 真實物件部分方法)
<?php
class UserService {
public function __construct(private UserRepository $repo) {}
public function getUserName(int $id): string {
$user = $this->repo->find($id);
return $user ? $user->name : 'Guest';
}
public function getUserCount(): int {
return $this->repo->count();
}
}
// 只 Mock find(),保留 count() 真實實作
class UserServiceTest extends TestCase {
public function testPartialMock() {
$realRepo = new SqlUserRepository($pdo); // 真實實作
$mockRepo = $this->createPartialMock(UserRepository::class, ['find']);
$mockRepo->expects($this->once())
->method('find')
->with(1)
->willReturn(new User(1, 'Alice'));
// count() 會呼叫真實實作 (需資料庫)
// 通常搭配資料庫測試或 SQLite 記憶體資料庫
}
}
?>
測試資料庫交互:Fake vs Mock
<?php
// Fake: 記憶體實作 (快速、無外部依賴)
class InMemoryUserRepository implements UserRepository {
private array $users = [];
public function save(User $user): void {
$this->users[$user->id] = $user;
}
public function find(int $id): ?User {
return $this->users[$id] ?? null;
}
public function count(): int {
return count($this->users);
}
}
// 整合測試使用 Fake
class UserServiceIntegrationTest extends TestCase {
public function testWithFakeRepo() {
$repo = new InMemoryUserRepository();
$repo->save(new User(1, 'Alice'));
$repo->save(new User(2, 'Bob'));
$service = new UserService($repo);
$this->assertEquals('Alice', $service->getUserName(1));
$this->assertEquals(2, $service->getUserCount());
}
}
?>
偽對象最佳實踐與反模式
| ✅ 最佳實踐 |
❌ 反模式 |
| Mock 介面而非具體類別 |
Mock 具體類別 (綁定實作細節) |
| 只 Mock 必要方法 (避免過度規範) |
所有方法都設定期望 |
| 驗證行為 (方法呼叫) 而非狀態 |
斷言內部屬性值 |
使用 with() 精確驗證參數 |
不驗證參數或用 withAnyArgs() |
測試名稱描述行為: testChargeReturnsTrueWhenGatewaySucceeds |
測試名稱模糊: testCharge |
| 外部依賴 (DB、API、檔案) 必須 Mock/Fake |
單元測試連真實資料庫 |
避免 Mock 過度:關注契約測試
<?php
// 契約測試:驗證實作符合介面契約
interface PaymentGatewayContract {
public function charge(float $amount, string $currency): bool;
}
// 所有實作必須通過此測試
abstract class PaymentGatewayContractTest extends TestCase {
abstract protected function createGateway(): PaymentGatewayContract;
public function testChargeReturnsBool() {
$gateway = $this->createGateway();
$result = $gateway->charge(100, 'USD');
$this->assertIsBool($result);
}
public function testChargeWithZeroAmount() {
$gateway = $this->createGateway();
$this->expectException(InvalidArgumentException::class);
$gateway->charge(0, 'USD');
}
}
// Stripe 實作測試
class StripeGatewayContractTest extends PaymentGatewayContractTest {
protected function createGateway(): PaymentGatewayContract {
return new StripeGateway('sk_test_...');
}
}
// Mock 測試只需驗證「正確呼叫介面」,不重複驗證介面契約
?>