iT邦幫忙

2026 iThome 鐵人賽

DAY 26
0
Software Development

同一套 Laravel 系統,測試怎麼寫才不會說謊系列 第 26 篇

Day 26:Fake 測試替身的實作細節——FakeHttpClient/FakeCaller 原始碼拆解

  • 分享至 

  • xImage
  •  

前言

「測試替身耗盡了假資料,應該回傳什麼?」

這個問題聽起來很小,但答案會決定你的測試替身是「幫你抓出真正的錯誤」,還是「安靜地讓錯誤溜走」。今天要把 Day 16 提過的兩個測試替身——FakeHttpClient 跟 FakeCaller——的原始碼整個攤開來看,重點放在一個容易被忽略的設計決定:假資料播完之後,該怎麼失敗。

今日目標

  • 讀完兩支測試替身的完整原始碼,理解它們各自負責什麼協定層級
  • 看懂 cassette 檔案怎麼被同一份錄影帶、依 header 特徵分流給兩支不同的替身
  • 理解「取不到下一筆假資料時該丟例外」這個設計,比「回傳空值」更誠實
  • 認識 SOAP 回應解析的具體做法:用正規表示式從 XML 裡挖出需要的片段

FakeHttpClient:處理標準 HTTP 層

class FakeHttpClient implements ClientInterface
{
    private array $entries;
    private int $index = 0;

    public function __construct(string $cassettePath)
    {
        $all = Yaml::parseFile($cassettePath);

        $this->entries = array_values(array_filter($all, static function (array $entry) {
            return ! isset($entry['request']['headers']['SOAPAction']);
        }));
    }

    public function sendRequest(RequestInterface $request): ResponseInterface
    {
        if (! isset($this->entries[$this->index])) {
            throw new RuntimeException("FakeHttpClient: no more cassette entries at index {$this->index}");
        }

        $entry = $this->entries[$this->index++];
        $response = $entry['response'];

        return new Response(
            $response['status']['code'],
            $response['headers'] ?? [],
            $response['body'] ?? '',
        );
    }
}

這支類別實作了 PSR-18 的 ClientInterface(跟 CLAUDE.md 裡提到的「外部 API 呼叫一律走 PSR-17/18 介面」是同一套慣例——因為程式碼依賴的是介面,測試時才能直接換成這支 Fake,不用改動任何呼叫端程式碼)。建構子讀進一份 YAML 格式的 cassette 檔案,用 array_filter 只留下沒有 SOAPAction header 的紀錄——這些是給一般 HTTP 客戶端消費的部分。sendRequest() 每次呼叫,就照順序取出下一筆紀錄組成回應物件。

FakeCaller:處理 SOAP 層

class FakeCaller implements Caller
{
    private array $entries;
    private int $index = 0;

    public function __construct(string $cassettePath)
    {
        $all = Yaml::parseFile($cassettePath);

        $this->entries = array_values(array_filter($all, static function (array $entry) {
            return isset($entry['request']['headers']['SOAPAction']);
        }));
    }

    public function __invoke(string $method, RequestInterface $request): ResultInterface
    {
        if (! isset($this->entries[$this->index])) {
            throw new RuntimeException("FakeCaller: no more cassette entries at index {$this->index}");
        }

        $entry = $this->entries[$this->index++];

        return $this->parseResponse($entry['response']['body']);
    }

    private function parseResponse(string $soapBody): ResultInterface
    {
        if (preg_match('/<QueryResult>(.*?)<\/QueryResult>/s', $soapBody, $matches)) {
            return (new QueryResponse)->withQueryResult(
                html_entity_decode($matches[1], ENT_QUOTES | ENT_XML1, 'UTF-8')
            );
        }

        if (preg_match('/<LoginResult>(.*?)<\/LoginResult>/s', $soapBody, $matches)) {
            return (new LoginResponse)->withLoginResult($matches[1]);
        }

        if (preg_match('/<AuthenticateResult>(.*?)<\/AuthenticateResult>/s', $soapBody, $matches)) {
            return (new AuthenticateResponse)->withAuthenticateResult($matches[1] === 'true');
        }

        throw new RuntimeException('FakeCaller: unable to parse SOAP response');
    }
}

這支類別實作的是 SOAP client 套件(phpro/soap-client)定義的 Caller 介面,過濾條件剛好跟 FakeHttpClient 相反——只留下有 SOAPAction header 的紀錄。它多做了一件 FakeHttpClient 不用做的事:把 SOAP 回應的 XML body 用正規表示式解析出來,包裝成對應的 Response 物件(依照回應內容裡出現哪個標籤,判斷這是查詢結果、登入結果,還是驗證結果)。

為什麼同一份 cassette 要拆給兩支替身消費

**同一支 Job 底層可能同時用到標準 HTTP 呼叫(例如抓外部網頁)跟 SOAP 呼叫(跟校務系統交換資料),錄下來的整份互動紀錄自然會混在同一份 cassette 檔案裡。**兩支替身分別用 SOAPAction header 存不存在,各自過濾出自己該處理的那一部分,互不干擾,也不需要維護兩份重複的錄製紀錄。這是一個乾淨的關注點分離:FakeHttpClient 不需要懂 SOAP 語法,FakeCaller 不需要處理一般 HTTP 回應的 header 組裝。

假資料播完了,該回傳什麼?

這是這兩支替身共同、也是最值得記住的設計決定:取不到下一筆假資料時,兩支替身都選擇直接丟出 RuntimeException,而不是回傳 null 或空陣列。

想像一下如果選擇回傳空值:測試呼叫次數不小心多於 cassette 錄製的次數時,程式碼可能拿著一個空值繼續往下跑,最後在某個完全不相關的地方出現「呼叫 null 的方法」這種難以追查的錯誤,讓你完全摸不著頭緒是哪裡出了問題。改成直接丟出帶著明確訊息的例外(連目前的 index 都印出來),測試會立刻在真正的問題點失敗,錯誤訊息直接告訴你「假資料不夠用了」,不用大海撈針。

這是測試替身設計裡很容易被忽略的一個細節:假資料耗盡是一種「異常狀況」,讓它明確地失敗,比讓它安靜地用空值蒙混過去,對後來維護的人友善得多。

今日思考題

回想你寫過的測試替身(Mock、Fake、Stub),當它被要求提供一個「你沒準備好的資料」時,它是直接失敗、還是回傳空值或預設值讓程式碼繼續跑?如果是後者,這會不會讓某些測試看起來通過,其實根本沒驗證到你以為驗證的東西?

今日重點回顧

  • FakeHttpClient 處理標準 HTTP 層,FakeCaller 處理 SOAP 層,兩者都實作對應的介面(PSR-18/phpro/soap-client 的 Caller),才能直接替換掉真實實作
  • 同一份 cassette 依 SOAPAction header 存不存在,分流給兩支替身各自消費,避免重複錄製
  • FakeCaller 額外負責用正規表示式解析 SOAP XML 回應,組成對應的 Response 物件
  • 假資料耗盡時直接丟例外,而不是回傳空值——讓測試在真正出問題的地方失敗,而不是留下一個難以追查的空值

明日預告

明天要看這個系統怎麼處理「同一套邏輯要維護兩個對外介面版本」這件事在測試上留下的痕跡:V1、V2 兩套介面的測試檔案幾乎是複製貼上再各自修改,這種情況到底該不該抽共用邏輯。


上一篇
Day 25:一套測試模式,套用在 6 支不同的 SOAP 同步 Job 上
下一篇
Day 27:雙版本 API 測試——複製貼上再各自修改,該不該抽共用邏輯?
系列文
同一套 Laravel 系統,測試怎麼寫才不會說謊 共 28 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言