「要 mock 兩種不同通訊協定的外部服務,是不是要各自準備一份假資料?」
這個系統要跟外部校務系統交換資料,走的是兩種不同的技術路徑:一般的 HTTP 請求,還有 SOAP 協議。如果各自準備各自的假資料來源,維護成本會隨著介面數量往上疊。今天要講這個系統怎麼解決這個問題——讓兩支負責不同協定的測試替身,共用同一份真實錄製下來的流量紀錄。
這個系統裡有兩支自製的測試替身:一支實作了 PSR-18 的 ClientInterface(標準 HTTP 客戶端介面),另一支實作了 SOAP client 套件定義的 Caller 介面。這兩支替身都做同一件事——依序播放一份 YAML 格式的「錄影帶」,裡面記錄著先前真實呼叫外部系統時的完整 request/response。
有意思的地方在於,這兩支替身讀的是同一份錄影帶,卻各自用不同方式解讀裡面的內容。實作 HTTP 客戶端介面的那支,直接把錄影帶裡的回應內容包成標準的 HTTP Response 物件回傳;實作 SOAP 呼叫器介面的那支,要多一道手續——用正規表示式解析錄影帶裡的 SOAP XML 內容,抓出裡面對應的結果標籤,重新組裝成 SOAP client 套件期待的回傳格式。
❌ 每種協定各自準備一份假資料
// HTTP 用的假資料
class FakeHttpClient
{
private array $httpFixtures = [/* 一份 HTTP 專用的假資料 */];
}
// SOAP 用的假資料
class FakeSoapCaller
{
private array $soapFixtures = [/* 另一份 SOAP 專用的假資料,跟上面沒有關聯 */];
}
兩份假資料各自維護,錄製一次真實流量之後,要手動整理成兩種不同的格式,如果外部系統的回應內容改了,要記得同步更新兩個地方。
✅ 共用同一份錄影帶,各自篩選自己看得懂的部分
class FakeHttpClient implements ClientInterface
{
public function sendRequest(RequestInterface $request): ResponseInterface
{
$entry = $this->findEntry($request, requireSoapAction: false);
return new Response($entry['response']['status'], [], $entry['response']['body']);
}
}
class FakeCaller implements Caller
{
public function __call($method, $arguments)
{
$entry = $this->findEntry(soapAction: $method);
return $this->parseSoapResponse($entry['response']['body']);
}
}
兩支替身都指向同一份 YAML 錄影帶,靠請求裡有沒有 SOAP 專屬的標頭(SOAPAction)互相過濾——同一批真實錄下來的流量,SOAP 呼叫自動被 FakeCaller 挑走,非 SOAP 呼叫自動被 FakeHttpClient 挑走。錄一次真實流量,兩種協定的測試替身都能用,維護的資料來源只有一份。
這個共用設計能成立的前提,是系統裡呼叫外部服務的程式碼,本來就是對著介面寫的(ClientInterface、Caller),而不是直接依賴某個特定的 HTTP client 套件或 SOAP client 套件。因為程式碼不在乎「實際執行的是哪一個實作」,只在乎「這個實作有沒有滿足介面的約定」,測試的時候才能直接把真實實作換成 Fake,不需要改動任何一行業務邏輯。這正是「打外部 API 一律用標準介面組 request,不要寫死特定 client」這種設計原則真正的好處——不是為了理論上的整潔,而是讓測試替身這種東西變得可能。
如果你的專案要跟多個外部系統整合,各自走不同的通訊協定,你手上的測試替身是各自維護各自的假資料,還是有辦法共用同一份來源?如果現在是各自維護,共用之後能省下多少重複的維護成本?
明天要把這份「錄影帶」機制攤開來看——23 個 cassette 檔案是怎麼分類組織的,檔名本身藏著什麼設計巧思。