在許多專案中——尤其是歷時已久、人員來來去去的專案——自動化測試是個謎。每個人按自己的方式寫測試,只因 wiki 裡某條塵封的規則要求,卻沒人能回答關於團隊測試策略的具體問題。本章為六角架構提供一套測試策略:針對每個架構元素,討論該用哪種測試來覆蓋它。

測試金字塔#

測試金字塔(test pyramid)是一個比喻,幫助我們決定各類型測試該追求多少數量。它的基本主張是:

  • 底部應有大量細粒度測試——建置便宜、易維護、執行快、穩定。這些是驗證單一單元(通常是一個類別)行為的單元測試(unit test)
  • 越往上,測試結合多個單元、跨越單元/架構/系統邊界,建置越貴、執行越慢、越脆弱(可能因設定錯誤而非功能錯誤而失敗)。金字塔告訴我們:測試越貴,就越不該追求高覆蓋率,否則會花太多時間建測試而非開發新功能。

Figure 8.1: 依測試金字塔,我們應建立大量廉價測試與較少昂貴測試

單元測試、整合測試、系統測試的定義因脈絡而異。本章的用法如下:

  • 單元測試:金字塔的基座。實例化單一類別並透過其介面測試功能。若被測類別對其他類別有非瑣碎的依賴,可用 mock 物件取代之。
  • 整合測試(integration test):實例化由多個單元組成的網路,透過入口類別的介面送入資料,驗證網路是否如預期運作。本書的解讀是:整合測試會跨越兩層之間的邊界,因此物件網路並不完整、或某處必須對著 mock 運作。
  • 系統測試(system test):啟動構成應用的整個物件網路,驗證某使用案例是否能穿過所有層如預期運作。

系統測試之上還可能有包含 UI 的端對端測試,但本書只討論後端架構,故不涉及。

測試金字塔如同任何指引,並非銀彈。它是不錯的預設,但若在你的脈絡中能廉價地建立與維護整合或系統測試,就應該多建——它們比單元測試更不受實作細節變動影響。這會讓金字塔的邊變陡,甚至倒過來。

用單元測試測試領域實體#

從架構中心的領域實體開始。回顧第 5 章的 Account 實體:其狀態由某過去時點的餘額(baseline 餘額)加上自那時起的一系列存提款(activity)組成。我們想驗證 withdraw() 方法如預期運作:

  • 這是一個單純的單元測試:把 Account 實例化到特定狀態,呼叫 withdraw(),驗證提款成功且對 Account 狀態產生預期的副作用。
  • 它易於設定、易於理解、執行飛快。這類單元測試是驗證領域實體所編碼業務規則的最佳選擇——因為領域實體行為幾乎不依賴其他類別,不需要其他類型的測試。

用單元測試測試使用案例#

往外一層,下一個要測的是以領域服務實作的使用案例。以第 5 章的 SendMoneyService 為例,轉帳會從來源帳戶提款、存入目標帳戶。為提升可讀性,測試以 given/when/then 三段式結構(常見於行為驅動開發)撰寫:

  • given:用 given...() 開頭的方法建立來源與目標 Account 並設定到正確狀態,並建立 SendMoneyCommand 作為輸入。
  • when:呼叫 sendMoney() 觸發使用案例。
  • then:斷言交易成功,並驗證來源與目標 Account 上某些方法已被呼叫。

底層用 Mockito 函式庫在 given...() 方法中建立 mock 物件,並用其 then() 方法驗證 mock 物件上某方法是否被呼叫。

mock 用得太多會給人虛假的安全感。mock 的行為可能與真實物件不同,導致測試全綠卻仍在 production 出問題。若不費太多力就能改用真實物件,就應該這麼做。例如上例可改用真實的 Account 物件——Account 是領域模型類別、無複雜依賴,成本不高。

由於被測的使用案例服務無狀態,then 段無法驗證某個狀態,只能驗證服務與其(被 mock 的)依賴有過某些方法互動。這意味著測試對被測程式碼的結構變動敏感,而不只是對行為敏感——重構時測試被迫修改的機率因此較高。

所以要慎重思考究竟要驗證哪些互動。不要像上例驗證所有互動,而應聚焦最重要的幾個;否則被測類別每改一次就得改測試,反而削弱測試的價值。這個測試雖仍是單元測試,卻已逼近整合測試(因為測試了對依賴的互動),但因使用 mock、不必管理真實依賴,仍比完整的整合測試更易建立與維護。

用整合測試測試 Web 配接器#

再往外一層是 adapter。回顧 Web adapter 透過 HTTP 接收輸入(如 JSON 字串),做些驗證,把輸入對映成使用案例期望的格式並傳入,再把結果對映回 JSON、以 HTTP 回應回傳。Web adapter 的測試要確保這些步驟都如預期:

  • 這是針對 Spring Boot 建構的 SendMoneyController 的標準整合測試。在 testSendMoney() 中送出一個 mock HTTP 請求觸發轉帳,用 isOk() 驗證回應狀態為 200,並驗證被 mock 的使用案例類別已被呼叫。
  • 我們並非真的透過 HTTP 協定測試——用 MockMvc 把它 mock 掉了,並信任框架正確地在 HTTP 之間轉譯,沒必要測試框架本身。

但「從 JSON 對映成 SendMoneyCommand 物件」的整段路徑都被覆蓋了。若如第 5 章把 SendMoneyCommand 建成自我驗證的 command,還能確保此對映產出語法上有效的輸入。

為何這是整合測試而非單元測試?因為 @WebMvcTest 標註會讓 Spring 實例化一整個物件網路(負責回應特定請求路徑、Java 與 JSON 互轉、驗證 HTTP 輸入等),而我們是驗證 controller 作為這個網路一份子的運作。由於 Web controller 與 Spring 框架緊密耦合,整合進框架測試才合理;若用純單元測試,就會失去對所有對映、驗證與 HTTP 部分的覆蓋。

用整合測試測試持久化配接器#

基於類似理由,持久化 adapter 也適合用整合測試而非單元測試覆蓋——因為我們不僅要驗證 adapter 內的邏輯,也要驗證對映到資料庫的部分。測試第 7 章建的 adapter(有載入帳戶與儲存新 activity 兩個方法):

  • @DataJpaTest 讓 Spring 實例化資料庫存取所需的物件網路(含連接資料庫的 Spring Data repository),並用 @Import 匯入額外設定,確保 adapter 對映所需的物件被加入網路。
  • loadAccount() 測試:用名為 AccountPersistenceAdapterTest.sql 的 SQL 腳本把資料庫設到特定狀態,透過 adapter API 載入帳戶,驗證其狀態符合 SQL 腳本所設的資料庫狀態。
  • updateActivities() 測試:反向操作——建立帶有新 activity 的 Account 交給 adapter 持久化,再透過 ActivityRepository 的 API 檢查 activity 是否已存入。

這些測試的關鍵是不把資料庫 mock 掉、實際打到資料庫。若 mock 掉資料庫,覆蓋的程式碼行數相同、行覆蓋率一樣高,但面對真實資料庫時仍可能因 SQL 敘述錯誤或對映錯誤而高機率失敗。

Spring 預設會在測試期啟動記憶體內資料庫,方便又開箱即用。但它多半不是 production 用的資料庫,即使測試對它全綠,真實資料庫仍可能出錯(各家資料庫廠商都愛實作自己的 SQL 方言)。因此持久化 adapter 測試應對著真實資料庫執行,Testcontainers 這類函式庫能按需啟動含資料庫的 Docker 容器。對著真實資料庫跑還有額外好處:不必同時維護兩套資料庫系統與兩套遷移腳本,大幅提升測試可維護性。

用系統測試測試主要路徑#

金字塔頂端是系統測試:啟動整個應用、對其 API 發出請求,驗證所有層協同運作。

六角架構的精髓在於為應用與外界建立明確的邊界,這讓應用邊界在設計上就極易測試——本地測試時,只需把 adapter 換成 mock adapter:

  • 左側(輸入):用呼叫應用輸入 port 的「測試驅動器」取代輸入 adapter,實作模擬使用者行為的測試情境。
  • 右側(輸出):用回傳預先指定值、模擬真實 adapter 行為的 mock adapter 取代輸出 adapter。

如此可建立覆蓋從輸入 port、經領域服務與實體、到輸出 port 的「應用測試(application test)」。

Figure 8.2: 以 mock 替換配接器,可在不依賴外界的情況下執行與測試應用

但作者主張:與其寫 mock 掉輸入輸出 adapter 的「應用測試」,更該寫覆蓋「從真實輸入 adapter 到真實輸出 adapter」整條路徑的系統測試。它能揪出許多 mock 掉 adapter 就抓不到的細微 bug,例如層間對映錯誤、或應用與外部系統之間的錯誤預期。前提是測試環境能啟動應用對接的真實外部系統。

具體做法:

  • 輸入側:能對應用發出真實 HTTP 呼叫,讓請求穿過真實 Web adapter——只需在本地啟動應用、讓它像 production 一樣監聽 HTTP。
  • 輸出側:啟動真實資料庫,讓測試穿過真實持久化 adapter。多數資料庫提供可本地啟動的 Docker 映像。若對接的是非資料庫的第三方系統,仍應設法找到(或建立)其 Docker 映像。
  • 若某外部系統沒有 Docker 映像,可寫客製的 mock 輸出 adapter 模擬它——六角架構讓這種替換很容易,日後映像可用時也能輕鬆換回真實 adapter。

當然也有對著 mock adapter 測試的正當理由。例如應用以多個 profile 執行、各 profile 針對相同的輸入輸出 port 使用不同的真實 adapter,這時我們想隔離「應用的錯誤」與「adapter 的錯誤」,只覆蓋六邊形的應用測試正是利器。但對於輸入輸出 adapter 相當固定的標準 Web + 資料庫應用,應聚焦系統測試。

轉帳的系統測試會對應用發出 HTTP 請求,驗證回應以及帳戶的新餘額。在 Java 與 Spring 中:

  • @SpringBootTest 讓 Spring 啟動構成應用的整個物件網路,並讓應用在隨機埠上對外公開。
  • 測試方法建立請求、送出、檢查回應狀態與帳戶新餘額。
  • TestRestTemplate(而非先前 Web adapter 測試的 MockMvc)送請求,意味著發出真實 HTTP 呼叫,更貼近 production 環境,也會穿過真實輸出 adapter。
  • 若應用對接其他系統,未必總能讓所有第三方系統都運行起來,這時仍可 mock 掉它們——六角架構讓這件事極簡單,只需把幾個輸出 port 介面 stub 掉。

作者刻意把測試寫得盡可能可讀,將所有醜陋邏輯藏進輔助方法。這些方法形成一套「領域特定語言(domain-specific language)」,用來驗證事物狀態。這在任何測試中都是好主意,在系統測試中尤其重要——系統測試比單元或整合測試更能模擬真實使用者,因此能從使用者視角驗證應用。合適的詞彙也讓最適合扮演使用者、卻多半不是程式設計師的領域專家,能讀懂測試並給予回饋(JGiven 這類函式庫提供了為測試建立詞彙的框架)。

系統測試會覆蓋許多與單元、整合測試相同的程式碼,那它還有額外好處嗎?有——它通常揪出不同類型的 bug(例如層間某處對映出錯,單靠單元與整合測試發現不了)。系統測試最能發揮威力之處,是組合多個使用案例構成情境,每個情境代表使用者穿過應用的典型路徑。只要最重要的情境都有通過的系統測試覆蓋,就能假定最近的改動沒弄壞它們、可以出貨了。

多少測試才夠?#

許多團隊答不出「我們該做多少測試」。覆蓋 80% 的程式碼行數夠嗎?要更高嗎?

行覆蓋率是衡量測試成效的爛指標。除了 100% 以外的任何目標都毫無意義,因為重要部分可能完全沒被覆蓋;而即使 100%,也無法確定每個 bug 都被消滅。

作者建議改以「我們對出貨有多自在」來衡量測試成效:若執行測試後信得過、敢出貨,就夠了。出貨越頻繁,對測試的信任越高;若一年只出貨兩次,沒人會信任那些一年只證明自己兩次的測試。

頭幾次出貨需要一點信心的跳躍,但只要把「修復並從 production bug 中學習」當作優先:每出現一個 production bug,就問「為什麼我們的測試沒抓到它?」、記錄答案、補上覆蓋它的測試。久而久之就會對出貨感到自在,那份文件還能提供衡量自身進步的指標。

為六角架構制定的一套測試策略是:

  • 實作領域實體時,用單元測試覆蓋它。
  • 實作使用案例服務時,用單元測試覆蓋它。
  • 實作 adapter 時,用整合測試覆蓋它。
  • 用系統測試覆蓋使用者穿過應用最重要的幾條路徑。

注意「實作時(while implementing)」這個措辭——當測試在開發功能的過程中完成、而非事後補上,它們就成為開發工具,不再像苦差事。但若每次新增欄位都得花一小時修測試,那就是哪裡做錯了:多半是測試對程式碼結構變動太敏感,應設法改善。一旦每次重構都得改測試,測試就失去了價值。

這如何幫助我打造可維護的軟體?#

六角架構乾淨地分離了領域邏輯與朝外的 adapter,這幫助我們定義清晰的測試策略:用單元測試覆蓋核心領域邏輯,用整合測試覆蓋 adapter。

  • 輸入與輸出 port 在測試中提供了非常醒目的 mock 點。每個 port 都可決定要 mock 還是用真實實作。若 port 各自小而專注,mock 它們就是輕而易舉而非苦差事——port 介面方法越少,「該 mock 哪些方法」的困惑就越少。
  • 若 mock 變成沉重負擔,或不知道該用哪種測試覆蓋某段程式碼,那就是警訊。在這層意義上,測試還肩負「金絲雀」的職責——警告我們架構有缺陷,把我們導回打造可維護程式庫的正軌。

至今我們大多孤立地談使用案例與 adapter,它們之間如何溝通?下一章來看一些設計資料模型的策略,這些模型構成它們之間的共通語言。