「測試裡要模擬外部 API 呼叫,不就是 Mockery::mock() 一行解決的事嗎?」
omnipay-ecpay 的測試裡完全找不到 Mockery,也沒有 PHPUnit 內建的 createMock()/getMockBuilder()。但它一樣要面對「測試不能真的打到綠界正式環境」這個問題。今天看它怎麼用另一種方式解決。
tests/Stubs/ 底下 4 個類別怎麼繞開真實的綠界 API 呼叫tests/Stubs/ 底下有 StubGateway、StubFetchTransactionRequest、StubRefundRequest、StubVoidRequest,全部靠繼承 + 覆寫,把「真的送 HTTP 請求給綠界」這個動作換成固定回傳值:
class StubRefundRequest extends RefundRequest
{
protected function doAction($data)
{
return [
'MerchantID' => $this->getMerchantID(),
'MerchantTradeNo' => $data['MerchantTradeNo'],
'TradeNo' => $data['TradeNo'],
'RtnCode' => '1',
'RtnMsg' => '',
];
}
}
真正的 RefundRequest::doAction() 會呼叫 $this->factory(...)->post(...),實際打 HTTP 請求到綠界的退款端點(Day 03 看過這段)。StubRefundRequest 什麼都不改,只覆寫這一個受保護方法,直接回傳一組寫死的成功回應。StubVoidRequest、StubFetchTransactionRequest 是一樣的套路。
再往上一層,StubGateway 繼承真正的 Gateway,只覆寫 fetchTransaction()/refund()/void() 這三個方法,讓它們建立的是 Stub 版本的 Request,而不是真正打 API 的版本:
class StubGateway extends Gateway
{
public function refund(array $options = [])
{
return $this->createRequest(StubRefundRequest::class, $options);
}
// fetchTransaction()、void() 同樣套路
}
GatewayTest 測試時用的就是 StubGateway,而不是真正的 Gateway——測試能驗證 refund()->send() 之後回傳的 getTransactionReference()、isSuccessful() 對不對,但完全不會真的打出一個 HTTP 請求。
Test Doubles 有幾種常見分類,區分的關鶥是「你在乎的是回傳值,還是呼叫過程」:
StubRefundRequest::doAction() 做的正是前者——它不管 doAction() 被呼叫幾次、參數傳得對不對,只在乎「呼叫之後回傳這組固定資料」,讓測試接下來能驗證 RefundRequest::sendData() 把這組資料轉成 VoidOrRefundResponse 之後,getTransactionReference()、getCode() 這些方法回傳的值對不對。這是典型的狀態驗證,耦合程度低——測試不會因為「這次呼叫了 factory() 幾次」這種實作細節而跟著改,只要 doAction() 的回傳值格式沒變,測試就不用動。
❌ 用 Mockery 模擬同一段行為
public function testRefund()
{
$request = Mockery::mock(RefundRequest::class)->makePartial();
$request->shouldReceive('doAction')
->once()
->with(Mockery::type('array'))
->andReturn([
'MerchantID' => '2000132',
'MerchantTradeNo' => 'xxx',
'TradeNo' => 'yyy',
'RtnCode' => '1',
'RtnMsg' => '',
]);
$response = $request->send();
self::assertTrue($response->isSuccessful());
}
✅ 這個套件實際的做法:定義一個具體的 Stub 類別
class StubRefundRequest extends RefundRequest
{
protected function doAction($data)
{
return [/* 固定回應 */];
}
}
// 測試裡直接 new 或透過 StubGateway 建立
$request = new StubRefundRequest($httpClient, $httpRequest);
兩種寫法都能達到「不真的打 API」的目的,差別在於:
doAction() 這個方法名稱、參數簽章緊緊綁在一起——如果哪天 RefundRequest::doAction() 改名或改參數,shouldReceive('doAction') 這行必須跟著改,而且這種綁定散落在每個用到 Mockery 的測試方法裡StubRefundRequest 類別,所有需要「假裝退款成功」的測試都共用它;如果 doAction() 的內部呼叫方式變了,只要回傳格式沒變,StubRefundRequest 完全不用動這個套件選 Stub 而非 Mock,換到的是「測試不會因為實作細節(呼叫了幾次、內部怎麼呼叫 SDK)而跟著碎裂」,代價是每個要繞開的外部呼叫,都得先定義一個對應的 Stub 類別。
Stub 適合驗證「拿到這個資料之後,後續邏輯對不對」,但它天生不驗證「呼叫的過程對不對」。如果今天要驗證的是「退款失敗時,有沒有正確呼叫記錄日誌的方法一次、且帶了正確的錯誤代碼」,這種副作用有沒有正確發生的驗證,Stub 顧不到,需要換成 Spy 或謹慎地用 Mock。這個套件目前的測試需求剛好都落在「驗證轉譯後的回傳值對不對」,所以 Stub 就足夠——這也呼應 Test Doubles 的黃金法則:用最簡單能解決問題的替身,不是預設用最強大的那個。
你手上的測試裡有沒有用 Mock 驗證「呼叫了幾次、參數對不對」,但其實你真正關心的只是「回傳值對不對」?如果換成 Stub,測試會不會因此變得更不容易因為重構而碎掉?
tests/Stubs/ 用繼承覆寫的方式做測試替身,屬於 Stub(控制回傳值、狀態驗證),不是 Mock(驗證互動行為)StubGateway 覆寫三個委派方法,讓它們建立 Stub 版本的 Request,繞開真實 API 呼叫明天回到覆蓋率這個話題:phpunit.xml 明明設定了完整的覆蓋率報表輸出,CI 卻用 --no-coverage 把它整個關掉,這中間發生了什麼事?