iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
IT Operation

AI 輔助開發下,測試如何保住品質防線系列 第 6

Day 06:不用 Mockery,這個套件怎麼假裝自己打過綠界 API

  • 分享至 

  • xImage
  •  

前言:不用 Mockery,這個套件怎麼辦?

「測試裡要模擬外部 API 呼叫,不就是 Mockery::mock() 一行解決的事嗎?」

omnipay-ecpay 的測試裡完全找不到 Mockery,也沒有 PHPUnit 內建的 createMock()/getMockBuilder()。但它一樣要面對「測試不能真的打到綠界正式環境」這個問題。今天看它怎麼用另一種方式解決。

今日目標

  • 看懂 tests/Stubs/ 底下 4 個類別怎麼繞開真實的綠界 API 呼叫
  • 分清楚這種做法在 Test Doubles 分類裡屬於哪一種,為什麼不是 Mock
  • 對照一組「改用 Mockery 寫同一個測試」的反例,具體比較兩種做法的耦合方式
  • 想清楚:這種選擇的成本是什麼,什麼情境下反而該換成 Mock

4 個 Stub 類別,做的都是同一件事

tests/Stubs/ 底下有 StubGatewayStubFetchTransactionRequestStubRefundRequestStubVoidRequest,全部靠繼承 + 覆寫,把「真的送 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 什麼都不改,只覆寫這一個受保護方法,直接回傳一組寫死的成功回應。StubVoidRequestStubFetchTransactionRequest 是一樣的套路。

再往上一層,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 請求。

這是 Stub,不是 Mock

Test Doubles 有幾種常見分類,區分的關鶥是「你在乎的是回傳值,還是呼叫過程」:

  • Stub:控制回傳值,讓依賴回傳你指定的資料,測試關心的是「拿到這個回傳值之後,後續行為對不對」,用的是狀態驗證
  • Mock:驗證互動行為,測試關心的是「這個方法有沒有被呼叫、呼叫幾次、帶了什麼參數」,用的是行為驗證

StubRefundRequest::doAction() 做的正是前者——它不管 doAction() 被呼叫幾次、參數傳得對不對,只在乎「呼叫之後回傳這組固定資料」,讓測試接下來能驗證 RefundRequest::sendData() 把這組資料轉成 VoidOrRefundResponse 之後,getTransactionReference()getCode() 這些方法回傳的值對不對。這是典型的狀態驗證,耦合程度低——測試不會因為「這次呼叫了 factory() 幾次」這種實作細節而跟著改,只要 doAction() 的回傳值格式沒變,測試就不用動。

❌ vs ✅:如果改用 Mockery 寫同一個測試

❌ 用 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」的目的,差別在於:

  • Mockery 版本doAction() 這個方法名稱、參數簽章緊緊綁在一起——如果哪天 RefundRequest::doAction() 改名或改參數,shouldReceive('doAction') 這行必須跟著改,而且這種綁定散落在每個用到 Mockery 的測試方法裡
  • Stub 類別版本只需要維護一個 StubRefundRequest 類別,所有需要「假裝退款成功」的測試都共用它;如果 doAction() 的內部呼叫方式變了,只要回傳格式沒變,StubRefundRequest 完全不用動

這個套件選 Stub 而非 Mock,換到的是「測試不會因為實作細節(呼叫了幾次、內部怎麼呼叫 SDK)而跟著碎裂」,代價是每個要繞開的外部呼叫,都得先定義一個對應的 Stub 類別。

什麼情境下 Stub 反而不夠用

Stub 適合驗證「拿到這個資料之後,後續邏輯對不對」,但它天生不驗證「呼叫的過程對不對」。如果今天要驗證的是「退款失敗時,有沒有正確呼叫記錄日誌的方法一次、且帶了正確的錯誤代碼」,這種副作用有沒有正確發生的驗證,Stub 顧不到,需要換成 Spy 或謹慎地用 Mock。這個套件目前的測試需求剛好都落在「驗證轉譯後的回傳值對不對」,所以 Stub 就足夠——這也呼應 Test Doubles 的黃金法則:用最簡單能解決問題的替身,不是預設用最強大的那個

今日思考題

你手上的測試裡有沒有用 Mock 驗證「呼叫了幾次、參數對不對」,但其實你真正關心的只是「回傳值對不對」?如果換成 Stub,測試會不會因此變得更不容易因為重構而碎掉?

今日重點回顧

  • tests/Stubs/ 用繼承覆寫的方式做測試替身,屬於 Stub(控制回傳值、狀態驗證),不是 Mock(驗證互動行為)
  • StubGateway 覆寫三個委派方法,讓它們建立 Stub 版本的 Request,繞開真實 API 呼叫
  • Stub 版本的耦合程度比 Mockery 低,代價是每個要繞開的呼叫都得先定義一個 Stub 類別
  • 選哪種替身要看「在乎回傳值還是在乎呼叫過程」,不是預設用最強大的工具

明日預告

明天回到覆蓋率這個話題:phpunit.xml 明明設定了完整的覆蓋率報表輸出,CI 卻用 --no-coverage 把它整個關掉,這中間發生了什麼事?


上一篇
Day 05:21 個測試方法,哪裡測得細、哪裡只測了半套
下一篇
Day 07:覆蓋率報表設定得很齊全,卻沒人在看
系列文
AI 輔助開發下,測試如何保住品質防線10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言