「要測試一個包著 SOAP 協議的 Client 類別,是不是一定要連上真實的外部服務才能驗證?」
前兩天講了測試替身的設計理念跟錄影帶的組織方式,今天要具體看一次完整的實作案例——這個系統怎麼把「注入 Fake」跟「驗證回傳結構」串成一套完整的單元測試流程,全程不真的打任何外部服務。
這個系統負責跟外部校務系統的資料查詢服務對接的 Client 類別,建構子接受一個實作了 Caller 介面的物件,而不是在類別內部自己 new 一個具體的 SOAP 呼叫器實例。這個設計選擇,正是 Day 16 講的「依賴介面」原則的具體落地——測試的時候,只要把 Day 16 提到的 FakeCaller 傳進建構子,就能完全取代真實的 SOAP 連線。
❌ 如果沒有介面注入,測試會被迫依賴真實連線
class DataGatewayClient
{
public function query(string $method, array $params)
{
$caller = new RealSoapCaller($this->wsdl); // 寫死在類別內部
return $caller->call($method, $params);
}
}
這種寫法沒辦法在測試裡替換掉真實的 SOAP 連線,要嘛測試被迫連上真實外部系統(慢、不穩定、依賴網路),要嘛乾脆放棄測試這段邏輯。
✅ 建構子注入讓測試替身可以直接介入
test('queries data gateway and returns structured response', function () {
$caller = new FakeCaller(cassette: 'campus-api/data-gateway/query');
$client = new DataGatewayClient($caller);
$response = $client->query('GetAccount', ['id' => 'test-001']);
expect(array_keys(get_object_vars($response)))
->toBe(['account', 'name', 'department', 'status']);
});
把 FakeCaller 傳進 DataGatewayClient 的建構子,呼叫查詢方法之後,不驗證回傳值裡每個欄位的實際內容(那是外部系統決定的,不是這段程式碼要負責的事),而是驗證回傳物件的欄位結構符合預期——這段程式碼真正的責任,是把 SOAP 回應正確轉換成一個型別明確的物件,欄位結構對不對,才是這段程式碼自己該負責的事。
這個 Client 類別的查詢邏輯裡,有一段跟目前時間有關的判斷(例如查詢的有效期間計算)。測試在執行前,會先用 Carbon 的測試工具把「現在時間」固定在一個確定的時間點,確保測試結果不會因為「今天到底是幾號」而每次執行都不一樣。這個手法會在 Day 24 更完整地展開,這裡先點名:任何跟「現在幾點」有關的邏輯,測試時都該先把時間鎖定住,不然測試會變成一個看執行日期臉色的不穩定測試。
把 Day 16 的測試替身設計、Day 17 的錄影帶組織方式、還有今天的建構子注入手法放在一起看,會發現這是一套完整的鏈路:業務邏輯依賴介面(讓替換變得可能)→ 錄一份真實流量存成有意義命名的 cassette(讓假資料有明確來源)→ 用建構子注入把替身塞進去(讓測試不用真的連線)→ 驗證回傳結構而不是回傳內容(測自己該負責的部分,不測外部系統決定的部分)。這四個環節缺一不可,少了任何一環,都會讓「不真的打外部系統也能驗證邏輯」這件事變得做不到。
回想你手上專案裡跟外部服務對接的類別,建構子是接受一個介面,還是在內部直接 new 一個具體的實作?如果是後者,要幫它寫一支不依賴真實連線的單元測試,需要先做哪一步調整?
Caller 介面,讓測試替身可以直接替換真實連線明天要看另一支測試得很紮實的單元測試——但要跟 Day 14 那支被跳過的登入測試對照著看,理解「底層元件測得仔細」不代表「整合起來的行為有安全網」。