摒棄無意義的單元測試

廢土王大錘發表於2021-06-19

在ThoughtWorks經歷過幾個專案後,我從一個只會莽code的糙漢子變成了一個會寫UT的糙漢子。寫過UT,也寫過整合測試,也實踐過TDD,發現了一些有趣的地方,跟大家分享下。

一些基礎的概念

作為一個開發,我對測試理解偏向在開發人員編寫的自動測試上。其中,最常見的是單元測試(UT)和整合測試(Integration Test),另外也有維護介面契約的契約測試等等。但在這篇部落格裡,主要討論的是最常見的單元測試和整合測試。

單元測試,覆蓋的範圍比較小,只針對一個元件(比如類),測試的目標往往是這個元件的公開方法。測試方法往往使用的是白盒測試。對於這個元件所需要的依賴,可以通過測試框架來模擬(mock)。

整合測試,覆蓋的範圍比較大,會將系統內的多個元件,按照實際執行時組裝,執行在測試框架內,測試這些元件整合後,是否能完成業務邏輯。測試的方法也更偏向使用黑盒測試。對於這些元件所需要的依賴或外部服務,可以通過測試框架來模擬,也可以編寫專門的測試類,或者直接使用專門的服務(比如記憶體資料庫)。

一些實踐的發現

事情源自我對一次返工的思考。當時的專案,因為歷史原因,只在專案中採用了單元測試,所以對於開發編寫的SQL語句,是否可以在資料庫中正確執行,是無法在只有單元測試的自動測試階段中檢驗出來的。

當時在準備Desk Check的我,望著全綠的測試報告陷入沉思:為什麼還有漏網之魚?!

從這個例子可以看出,在應用服務的開發的過程中,我們無法避免我們的應用與外部服務(例如資料庫、Web Service等)的互動。而對這些外部服務的互動,我們往往依賴於框架。我們可以mock框架裡介面的輸出,但是無法確保我們的輸入是否正確。比如,開發編寫的一條SQL,除非將其執行在真正的資料庫服務中,否則我們無法保證這條SQL是否可以正確的執行,或者滿足我們的業務需求。

單元測試的侷限性不僅僅這一點,AOP做為OOP的重要補充,廣泛的應用在我們的開發過程中。針對AOP邏輯(比如引數校驗、許可權校驗等)的測試,是無法通過單元測試完成的,因為AOP的程式碼在被測試程式碼之外。

還有,單元測試往往使用白盒測試的方法,比如在Controller的單元測試中,會檢查是否呼叫了某個Service的某個方法。但如果在重構中,這個Service的這方法的簽名,或者返回值發生了變化,面對著測試中幾十上百個編譯錯誤,你是否突然覺得原來的程式碼也挺眉清目秀的?

最後,我在重構的過程中,發現了很多方法中的部分分支,只會在單元測試中被呼叫,並沒有在實際業務中執行過。也就是說,我辛辛苦苦看明白的一大段程式碼,沒!卵!用!結果,只能在滄海桑田的感慨中,含淚刪除。

所以,從我經歷過的例子中可以看出,如果僅僅依靠單元測試來保證應用服務的正確性,那麼就會出現以下問題:

  1. 對於外部系統的呼叫,無法保證相關介面輸入的正確性;
  2. 無法保證AOP功能的正確性;
  3. 重構難度大,不適合敏捷實踐;
  4. 缺乏大局觀,存在過度設計的可能;

那麼,在採用整合測試後,情況是否能得到好轉呢?

整合測試的應用

一開始,我使用整合測試,只是為了檢查編寫的SQL是否可以正確的執行:將H2記憶體資料庫整合到測試中,啟動Spring容器,只載入Repository例項並執行。

然後我就發現:我可以將連線著H2資料庫的Repository例項注入到Service中,這樣我就可以省去一些在ServiceTest中對於Repository的mock。

接著,我又嘗試將注入了真實Repository的Service注入到Controller中,也就是說幾乎將應用服務完整的執行在測試容器中。那麼我只需要拼接一個HTTP請求並傳入,就可以從這個執行在測試容器的應用服務中得到HTTP響應。

這時,我意識到:如果把應用服務看作一個大的元件,把它對外提供的RESTFul API看作元件的公開方法。那麼我們更應該關注這些公開方法的輸入輸出,而不是其內部元件的實現。那麼我們更應該mock的是應用服務所依賴的外部服務,而不是內部的私有方法。

如此看來,那些針對Controller、Service、Repository的單元測試,通通可以摒棄!只需要拼接一個HTTP請求,傳送到執行在測試容器中的應用服務,校驗返回值,檢查記憶體資料庫中資料的變更。這些測試用例,是可以參考QA小姐姐們的。依據TDD的理論指導,我們應該優先完成測試用例的編寫,再去動手實現

那麼再來看下之前單元測試遇到的四個問題:

  1. 對於外部系統的呼叫,無法保證正確性;

    對於資料庫服務來說,在整合測試中,往往會引入H2記憶體資料庫來模擬真實環境中的資料庫服務。一般不是太特殊的SQL,都可以在H2記憶體資料庫中執行。

    對於Web Service,我暫時還沒有很好的解決方案。之前有過CXF的專案經歷,在測試環境中,魔改了client,從測試檔案中讀取XML響應體。但這麼做也無法確保我們應用的對外呼叫引數是否輸入正確。

  2. 無法保證AOP功能的正確性;

    在整合測試中,整個應用服務都已經執行起來,所有AOP都是正常工作的,通過調整請求中的引數和頭資訊,就可以觸發AOP的攔截,進而檢查AOP邏輯的正確性。

  3. 重構難度大,不適合敏捷實踐;

    在整合測試中,所有的測試用例只在應用服務的外部檢查,並不依賴內部的實現,所以如果重構時,對外的介面沒有變化,無需修改測試用例,只需要完成實現的重構即可。

  4. 缺乏大局觀,存在過度設計的可能;

    如果我們的測試用例完整的覆蓋了業務需求,那麼執行過這些測試用例後,還存在著沒有行覆蓋到的程式碼,那麼這些程式碼就是過度設計的程式碼,可以考慮刪除或者檢查測試用例是否存在缺失。

帶來的挑戰

整合測試可以解決很多單元測試無法解決的問題,但也會帶來新的挑戰:

  1. 對於卡片,要拆分為前端卡與後端卡甚至更多的有著更多技術細節的子卡。在這些子卡中,BA需要清楚地認識到,想要達成業務需求,介面的格式應該是怎樣,介面呼叫前後的資料變化。這些技術細節可以依賴團隊裡的TL或Sr Dev。

    這樣的實踐,有些傳統開發中概要設計的味道。雖然很多情況下,我們不會將卡片拆至如此細的粒度,但是這麼做,可以更早的意識到這張卡的依賴項,同時也可以方便QA,針對這個介面設計測試用例。

  2. 由於整合測試中的測試用例可以完全來自QA,如果這些測試用例完全來自QA,可能需要QA摸索出一條新的工作節奏。如果這些測試用例完全來自開發,QA再獨立寫一套,那麼可能會存在重複工作的現象。如果測試用例由開發編寫,再由QA稽核,這可能是個好實踐,但我還沒有嘗試過。

  3. 在後端技術棧中,我們會使用資料庫版本管理工具來管理資料庫版本。在Java的技術棧中,通常我們會使用Flyway。但Flyway的一個侷限性是就是過度依賴SQL,這使得一些DDL可以執行在真實環境中資料庫,但卻無法執行在H2資料庫。所以在這裡,我推薦Liquibase,這個框架會對資料庫的更新做出自己的抽象,可以做到一個指令碼執行在多種廠商的資料庫,更適合整合測試的場景。

  4. 由於整合測試要啟動一個真實的容器,所以自動測試時間也會更長,構建時間也會更長,不過還是在可以接受的範圍內。

重申下適用範圍

儘管我這篇部落格的主題是呼籲大家摒棄無意義的單元測試,但這是建立在我們所經歷的大部分工作,都是針對介面的開發。在這樣的工作中,單元測試有著很大的侷限性,而整合測試有著更好的匹配度。

但如果你在開發一個類庫,或者在DDD建模的早期,在這些場景中,單元測試才是更好的選擇。

相關文章