第 4 / 6 部 待錄

配接器與測試策略

《整潔架構實戰》Podcast 準備稿:配接器與測試策略

書名: 整潔架構實戰(Get Your Hands Dirty on Clean Architecture) 作者: Tom Hombergs 系列: 設計品味系列 公開標題建議: 設計品味系列|整潔架構實戰.第四集:把資料庫變成 App 的插件,順便把測試金字塔講清楚 (4/6) 涵蓋範圍: 第 6 章「實作 Web 配接器」、第 7 章「實作持久化配接器」、第 8 章「測試架構元件」

背景速覽

上一集寫完了六邊形的「核心」(use case + 領域模型),這一集往外走,把六邊形外圈的兩個主角——Web adapter(輸入側)和 persistence adapter(輸出側)——都實作出來,再用第 8 章補上一套完整的測試策略。三章合一集,因為它們其實是同一條主線:adapter 只做轉譯、不做領域邏輯,而這條乾淨的邊界讓測試策略變得清晰可推導

這集很「工程」,但藏了兩個全書最實用的洞見:一是「把持久化做成插件」如何徹底翻轉第一集講的「以資料庫為中心」原罪;二是作者對測試金字塔、對「mock 該用多少」、對「多少測試才夠」這些天天吵的問題,給了非常務實、跳脫教條的答案。

一句話重點

Web adapter 和 persistence adapter 都只是「把外部協定(HTTP、SQL)翻譯成對 use case 的呼叫、再翻譯回去」的轉接器,本身不含領域邏輯——而這條乾淨邊界,讓你能用「單元測核心、整合測 adapter、系統測主路徑」推導出一套清晰的測試策略。

值得討論的重點

重點一:Web adapter 的本質——一個 HTTP 翻譯機,外加「為什麼要多一層 port」

Web adapter 是「驅動/輸入」adapter:接收外界請求,翻譯成對核心的呼叫。它的職責清單其實很長——對映 HTTP 請求、做授權檢查、驗證輸入、對映成 use case 的輸入模型、呼叫 use case、把輸出對映回 HTTP、回傳。但作者反覆強調的核心是:這些職責很重,但它們全是「應用層不該操心」的事;任何跟 HTTP 有關的東西,都不准洩漏進應用層。一旦核心知道外界在用 HTTP,你就失去了「用別的非 HTTP adapter 跑同一套領域邏輯」的選項。

這裡作者回答了一個很多人會問的問題:控制流是 Web → service,方向一致,那為什麼還要在中間多插一個輸入 port,不直接呼叫 service?答案是——port 是「外界能跟核心互動之處」的規格說明。有了它,你一眼就知道應用有哪些對外接口,對接手遺留程式碼的人是無價資訊;它還讓你能為應用打造「測試驅動器」(第 8 章用得到)。當然,第 11 集會講到這也是個可以「有意識略過」的捷徑。

關於輸入驗證,作者區分得很細:Web adapter 也驗證,但驗的是「能不能把 Web 的輸入模型轉成 use case 的輸入模型」——任何阻礙這個轉換的就是驗證錯誤,而不是重複實作 use case 已經做過的驗證。最後他主張 controller 要「寧多勿少」:別用一個 AccountController 接所有帳戶請求,而是每個操作一個小 controller,各自帶專屬輸入模型。理由是小類別好懂好測,還能避免「AccountResource 變成什麼都裝的水桶」。順帶一提命名——RegisterAccountCreateAccount 更能傳達意義。

重點二:把持久化做成「插件」——徹底翻轉第一集的原罪

還記得第一集說分層架構的原罪是「以資料庫為中心、一切疊在持久化層上」嗎?第 7 章就是來反轉它的。作者不再說「持久化層」,而是說一個「persistence adapter」——它是被驅動/輸出 adapter,被應用呼叫,而不是反過來。領域服務呼叫 port 介面,port 由 persistence adapter 實作。

這層間接(indirection)的價值:只要 port 的契約被滿足,你就能在 persistence adapter 裡為所欲為,而不影響核心。重構持久化、換 ORM、甚至整個換掉資料庫技術,核心都渾然不覺。關鍵一句話:輸入輸出模型住在應用核心,而不是 persistence adapter 內部——所以 adapter 的變動不會反向汙染核心,依賴方向才正確。

這章還引出一個重要的 SOLID 應用——介面隔離原則(ISP)。常見做法是給一個 entity 一個大 repository 介面、塞滿所有操作。但這樣每個 service 都得依賴一個「寬」介面,即使只用其中一個方法。作者引 Uncle Bob 的話:「依賴某個帶著你不需要的包袱的東西,會帶給你意料之外的麻煩。」解法是把寬介面拆成一個個小 port(常常一個 port 一個方法,像 LoadAccountPortUpdateAccountStatePort),讓 service 只依賴它真正需要的,測試時也不必煩惱該 mock 哪些方法。寫程式變成「隨插即用」——要什麼 port 就插什麼,沒有包袱。

交易邊界放哪?作者也答了:交易要涵蓋一個 use case 內所有寫入,所以不能放在 persistence adapter(它不知道整個 use case 的全貌),得放在協調這些呼叫的 service 上——Java 裡就是在 service 加 @Transactional。有人會說「這不就在純淨的領域裡引入框架依賴嗎」,作者很乾脆:是的,但換來交易處理,划算,我們總不會為了純淨自己手刻交易機制。

重點三:測試金字塔——以及作者對它的兩個「叛逆」修正

第 8 章給六邊形架構配了一套測試策略,骨架就是測試金字塔:底部大量便宜的單元測試,越往上越貴越慢越脆弱。對映到架構:

  • 領域實體 → 單元測試(Account 行為幾乎不依賴別人,飛快)
  • use case service → 單元測試(用 Mockito,given/when/then)
  • adapter → 整合測試(要驗證對映到 HTTP / 資料庫的部分)
  • 最重要的幾條使用者路徑 → 系統測試

但作者對金字塔有兩個叛逆的修正,我覺得比金字塔本身更值得講:

叛逆一:別迷信金字塔形狀。 作者說金字塔是個不錯的「預設」,但若在你的脈絡裡整合/系統測試能廉價地建立與維護,就該多寫——因為它們比單元測試更不受實作細節變動影響。這會讓金字塔的邊變陡、甚至倒過來。

叛逆二:mock 太多會給你虛假的安全感。 use case 測試常用 mock,但作者警告:mock 的行為可能跟真實物件不同,導致測試全綠、production 照爆。能不費力就用真實物件,就該用(比如測 service 時用真的 Account 而不是 mock 它)。更深一層的洞見:因為 use case service 是無狀態的,測試只能驗證「跟依賴有過哪些方法互動」——這讓測試對程式碼的「結構」變動敏感,而不只是對「行為」敏感,重構時容易被迫改測試。所以別驗證所有互動,只驗證最重要的幾個。

重點四:整合測試打真資料庫、系統測試打真路徑、以及「多少測試才夠」

作者在 adapter 測試上有兩個很硬的立場:

  • persistence adapter 測試一定要打真資料庫,不准 mock 掉資料庫。因為 mock 掉的話,行覆蓋率一樣漂亮,但面對真實資料庫的 SQL 方言差異、對映錯誤,照樣會爆。Spring 預設啟記憶體資料庫很方便,但那多半不是 production 用的庫——應該用 Testcontainers 起一個真資料庫的 Docker 容器來測。
  • 系統測試該打從真輸入 adapter 到真輸出 adapter 的完整路徑,而不是寫那種把兩端 adapter 都 mock 掉的「應用測試」。系統測試能揪出層間對映錯誤這類「mock 掉就抓不到」的細微 bug。六邊形架構讓這件事很容易——本地起應用、發真 HTTP、接真資料庫,第三方系統沒 Docker image 就 stub 掉幾個輸出 port。

最後那個天天有人問的問題:多少測試才夠? 作者直接說「行覆蓋率是爛指標」——除了 100% 以外任何數字都沒意義,而且就算 100% 也不保證沒 bug。他的替代衡量標準很有人味:「我們對出貨有多自在」。能跑完測試就敢出貨,就夠了。出貨越頻繁、對測試的信任越高。具體做法是把「從 production bug 學習」當優先:每出一個線上 bug,就問「為什麼測試沒抓到」、補上覆蓋它的測試。久了你就會對出貨越來越自在,那份記錄還能當成你進步的量尺。還有一句點睛——測試要「在開發功能的過程中寫,而不是事後補」,這樣它是開發工具、不是苦差事;如果每次加欄位都要花一小時修測試,那是測試對結構太敏感,哪裡做錯了。

注意事項

⚠️ 「每個操作一個 controller、各帶專屬模型」對大型 API 是好紀律,但對一個只有十幾個端點的小服務,可能切得太碎——一堆只有一個方法的 controller 類別,反而增加導航成本。作者的「寧多勿少」是有前提的:當共用會逼出「什麼都裝的水桶模型」時才值得拆。小而清楚的應用,一個聚合一個 controller 往往剛好。

⚠️ 「整合/系統測試該多寫、金字塔可以倒過來」這個叛逆主張要小心適用條件——它成立的前提是「整合/系統測試能廉價地建立與維護」。在 CI 慢、環境難起、Testcontainers 拖垮建置時間的現實裡,倒金字塔會讓你的 feedback loop 慢到沒人想跑測試。務實的版本是:核心領域邏輯用快速單元測試保底,整合/系統測試挑「最重要的幾條主路徑」覆蓋,而不是無腦把金字塔翻過來。

⚠️ 「一個 port 一個方法」是 ISP 的漂亮極端,但作者自己也補了一句:某些資料庫操作高度內聚、總是一起用,硬拆成一堆單方法 port 反而碎得難管理。ISP 的精神是「別逼客戶依賴它不用的方法」,不是「方法數一律砍到一」。判準是內聚性——常一起用的綁一起,沒關係的才拆開。

⚠️ 「對出貨有多自在」當測試指標很有人味,但它有點主觀、也難向管理層交代(老闆問「我們測試覆蓋夠不夠」,你總不能只回「我感覺還行」)。它更適合當「團隊內部的北極星」,搭配「production bug 逃逸率」這類客觀指標一起看,會比單獨用更站得住腳。

專家補充

💡 「把持久化做成插件」這件事,是 Uncle Bob「資料庫是細節」這個激進主張的工程實現。在這套架構裡,資料庫真的退化成一個可抽換的 adapter——理論上你能從 PostgreSQL 換成 MongoDB 換成一個記憶體 Map,核心一行不改。雖然作者誠實說「整個換掉資料庫的機率通常很低」,但這個能力的真正價值不在「換庫」,而在「可測試性」——你可以隨手做一個 in-memory adapter 來測,這才是天天用得到的紅利。

💡 作者堅持「測試打真資料庫、用 Testcontainers」這點,正好踩在業界一個重要轉向上。早年大家愛用 H2 這類記憶體庫測試(快),但被 SQL 方言差異坑多了之後,Testcontainers(2015 年後流行)讓「對著和 production 一模一樣的資料庫跑測試」變得便宜,現在幾乎是 JVM 整合測試的事實標準。這集可以順勢推坑:如果你還在用 H2 測 production 是 Postgres 的應用,你的整合測試其實在自欺欺人。

💡 「測試對結構敏感而非對行為敏感」是個極深刻、卻常被忽略的洞見。過度 mock + 驗證所有互動,會讓你的測試變成「程式碼結構的鏡像」——你一重構,測試就紅一片,但其實行為根本沒變。這種測試是「重構的阻力」而非「重構的安全網」,跟測試存在的目的背道而馳。Kent Beck、以及《Unit Testing》作者 Vladimir Khorikov 都反覆強調:好的測試驗「行為/結果」,不驗「實作細節」。這集可以把它當成一個能立刻拿去檢查自己測試的試金石。

💡 「對出貨有多自在」這個衡量標準,其實接到了 DevOps 的核心指標——DORA 的「部署頻率」和「變更失敗率」。作者用感性的語言講的東西,DORA 用數據量化了:高效團隊的特徵就是「敢頻繁出貨、且出貨很少出事」。所以「對出貨自在」不是玄學,它是可以被 DORA 指標背書的工程文化。

討論問題

🎙️ 你的 Web 層有沒有「HTTP 細節洩漏進核心」的情況?比如 service 方法直接收 HttpServletRequest、或領域物件上掛著 JSON 序列化標註?如果要把這些洩漏堵回 adapter,工程量大嗎?

🎙️ 你的 repository 介面是一個塞滿方法的大胖子,還是切成一個個小 port?寫某個 service 的測試時,你能立刻說出「該 mock 哪幾個方法」嗎,還是每次都要回去翻?

🎙️ 你現在的整合測試,是打真資料庫還是 mock 掉、或用 H2?有沒有發生過「測試全綠、上 production 卻被某個 SQL 方言或對映坑掉」的事?

🎙️ 把「行覆蓋率」換成「你對出貨有多自在」當測試目標——你現在敢不敢跑完測試就直接出貨?如果不敢,是哪一塊讓你心虛?那塊是不是就是你下一個該補測試的地方?

更大範圍關聯

  • 接 Uncle Bob《Clean Architecture》:「資料庫是細節、是可抽換的 I/O 裝置」這個激進主張,在這兩章被工程化成「persistence 是 adapter / 插件」。理論在那本,落地在這本。

  • 接 SOLID 的 ISP(介面隔離原則):第 7 章是 ISP 的架構級實戰,補齊了第二集的 SRP + DIP。三個原則合起來,幾乎撐起整本書的依賴管理。

  • 接測試金字塔(Mike Cohn 提出、Martin Fowler 推廣)與 Testcontainers 生態:這集是金字塔的「實戰版 + 叛逆修正版」,並把「打真資料庫」這個現代最佳實踐講透。

  • 接 DevOps / DORA 指標:「對出貨有多自在」對應 DORA 的部署頻率與變更失敗率。本書的測試哲學,其實是持續交付(Continuous Delivery)文化在架構層的投影。

  • 接《Unit Testing》(Vladimir Khorikov):「別過度 mock、別驗證實作細節、測試該對行為敏感」是現代測試思潮的共識,這章是它在六邊形架構裡的具體展現。

錄製建議

目標約 20 分鐘,單人主講。建議節奏:

  • 0–2 分:開場。「上集寫完核心,這集往外做兩個 adapter,順便把測試金字塔講清楚——三章一集,因為它們是同一條主線」。
  • 2–6 分:Web adapter。重點放「它只是 HTTP 翻譯機、領域邏輯不准洩漏進核心」+「為什麼還要多一層輸入 port」+「controller 寧多勿少」。
  • 6–10 分:persistence adapter。回扣第一集的原罪——「把持久化從世界中心降級成一個插件」,講 ISP 切小 port 的「隨插即用」體驗,順帶交易邊界放 service。
  • 10–15 分:測試金字塔 + 兩個叛逆修正。對映四種測試到四個架構元素,然後重點講「別迷信金字塔形狀」和「mock 太多給你虛假安全感 / 測試該對行為而非結構敏感」。
  • 15–18 分:兩個硬立場——整合測試打真資料庫(Testcontainers,吐槽 H2 自欺欺人)、系統測試打真路徑。
  • 18–20 分:收尾用「多少測試才夠」這個金句——「行覆蓋率是爛指標,改問你對出貨有多自在」,並預告下一集講層間對映策略和怎麼把整個應用組裝起來。

口氣提醒:這集資訊量大,務必用「回扣前集」串起來(持久化插件回扣第一集原罪、ISP 回扣第二集 SOLID),避免變成三章流水帳。測試那段是聽眾最有共鳴的,留足時間,多用「測試全綠卻上線爆掉」的共感場景。