摒棄無意義的單元測試


在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建模的早期,在這些場景中,單元測試才是更好的選擇。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM