作者 | 王浩(光酒)
什么是單元測試
《單元測試的藝術》中對單元測試的定義:
一個單元測試是一段自動化的代碼,這段代碼調用被測試的工作單元,之后對這個單元的單個最終結果的某些假設進行校驗。單元測試幾乎都是用單元測試框架編寫的;只要產品代碼不發生變化,單元測試的結果是穩定的。
為什么需要單元測試
在我看來,單元測試的意義可以總結如下三點:
- 單元測試是保證你寫的代碼是你想要的結果的最有效辦法
- 單元測試幫我們塑造設計
- 單元測試是最好的文檔之一?
單元測試描述了代碼的預期行為,可以最有效地保證代碼正確運行,減少代碼缺陷;由于單元規模較小,當因為代碼變更出現問題的時候,可以幫助我們快速定位問題;有單元測試覆蓋的代碼,讓我們更有信心,敢于放心做代碼重構;寫單元測試的過程往往伴隨著代碼重構,如果發現一段代碼單元測試很難寫,就需要反思我們的設計,進而重構促進代碼設計的優化,幫助我們塑造設計;同時單元測試也是一個最佳的、自動化的、可執行的文檔;沒有單測覆蓋的代碼,是很難被維護的。
什么是有效的單元測試
可讀、可維護、可信賴、快速執行!《單元測試的藝術》中描述優秀單元的特性:
- 它應該是自動化的,可重復執行;
- 它應該很容易實現;
- 它應該第二天還有意義;
- 任何人都應該能一鍵運行它;
- 它應該運行速度很快;
- 它的結果應該是穩定的(如果運行之間沒有進行修改的話,多次運行一個測試應該總是
- 返回同樣的結果);
- 它應該能完全控制被測試的單元;
- 它應該是完全隔離的(獨立于其他測試的運行);
- 如果它失敗了,我們應該很容易發現什么是期待的結果,進而定位問題所在。
可讀性
“一般程序員寫得出計算機能讀懂的代碼。優秀程序員寫得出人能讀懂的代碼” — 馬丁·福勒可讀的代碼才是可維護的;難以閱讀和理解的測試用例,最終的結果就是刪掉它,因為維護成本過高。可讀性高于純粹的性能。
可維護性
團隊內使用一套范式的結構,有助于使之更好用,快速定位問題;消滅代碼中的壞味道。
可信賴
可信賴的含義:
- 測試可重復;
- 測試與依賴環境隔離;
- 只測試不進行驗證是不可靠的測試;
- 在測試類中不要依賴與測試的順序;
- 測試的結果是精準的:校驗的精準以及錯誤問題的精準定位;
快速執行
保證單測快速執行,縮短反饋時長;
為什么有效的單元測試如此重要
無效的單元測試是沒有意義的,反而會增加維護成本,最終導致單元測試的失敗!
如上圖所示,坐標中任意一個點,其與橫縱坐標垂直線所形成的矩形面積代表CI為團隊帶來的價值,那么在我看來有兩個關鍵的因素:橫坐標是單元測試的基礎能力建設,縱坐標則是有效的單元測試;沒有有效的單元測試,基礎能力做出花來也毫無意義!完善的基礎能力同時也幫助我們更低成本的寫出有效的單元測試。
如何寫有效的單元測試
我們以Flutter為例,來一起討論如何寫有效的單元測試;
使用測試框架
Flutter官方提供的測試框架:
- flutter_test
- integration_test
統一的編碼約定
不論是AAA(Arrange-Act-Assert)還是GWT(Given-When-Then),統一的編碼約定幫助保證測試代碼的可讀性、可維護性。
使用測試替身
測試替身幫助我們隔離被測試代碼,加速執行速度,保證測試代碼是可信賴的;
- Dummy:一種什么也不做的實現方式。接口中的每個方法什么也不做,如果方法有返回值,返回的值盡量接近null或者0。
- Stub:Dummy的一種,Stub的函數并不返回null或0,而是返回能推動函數沿預定路徑被測試的值。
- Spy:Stub的一種,它返回測試所需的特定值,推動系統沿著我們期望的路徑前行。然而,Spy能記住對它所做的事,并允許測試詢問。
- Mock:Spy的一種,它返回測試所需的特定值,推動系統沿著我們期望的路徑前行,而且還會記住對它所做的事。不過,Mock還知道我們的預期,基于這些預期,判斷測試是否通過;換而言之,Mock中寫明了測試斷言。
- Fake:Fake是一種模擬器,它實現基礎業務規則,這樣測試就能要求該Fake按需要的路徑執行。
一個測試應當只檢查一件事
明確測試意圖,一旦出錯可以精準定位問題;
一個測試只有一個模擬對象
避免過多模擬對象,一個測試用例的校驗內容盡量簡單;
避免冗余測試
冗余測試會提高維護成本;
避免條件邏輯
條件邏輯會讓你的單元測試更難以維護,出問題不容易排查,不夠精準;
單測需要確定性
避免脆弱測試,Mock不確定的依賴:時間、隨機數、并發性、基礎設施、現存數據、持久化、網絡等等;
測試快速執行
避免sleep等操作,導致測試執行緩慢;
避免過度指定
對于過度指定的討論,其核心問題就是要我們判斷哪些是單元測試應該覆蓋的,哪些是應該留給其他測試手段的。如果一個場景,單元測試覆蓋之后,導致經常單測失敗,需要不斷更新維護,那就可以考慮不做單元測試覆蓋。像素完美是一個典型的、經常拿出來討論的例子,Flutter的Golden Test就是一個golden master testing的例子;《有效的單元測試》中關于像素完美的討論:
像素完美:顧名思義,是一種特定于圖形和圖像生成的測試壞味道。它混雜了魔法數字和基本斷言,使得測試極難閱讀也極其脆弱。
這種測試幾乎無法閱讀,因為即使測試在語義上是處于高層概念的,卻仍然會針對硬編碼的底層細節例如像素坐標和顏色來進行斷言。指定坐標上的像素是黑還是白,與兩個圖形是否相連或堆疊的概念是有區別的。
這種測試極其脆弱,因為即使很小的和不相關的輸入變化——是否是另一個圖像,或圖形對象的渲染方式——都足以影響輸出、打破測試,誰讓你非要精確地檢查像素坐標和顏色呢。同樣的問題在采用golden master技術時也會遇到,其做法是事先將圖像錄制下來,并手工檢查其正確性,以后再進行測試時就將渲染出的圖像與之進行比對。
這些可不是我們愿意去維護的測試。我們不希望帶著這種脆弱的精確度去編寫測試,而是使用模糊匹配和智能算法來代替繁瑣的數值比較。
對于特定場景,Golden Test是一個非常有效的手段,但需要非常謹慎的評估;慎用Golden Test!
不要寫永不失敗的測試,不要寫沒有校驗的測試
單測需要對明確的邏輯校驗,永不失敗的測試或者沒有校驗的測試是不可信賴的。
測試不要名不副實
避免測試的描述與測試內容不符;測試結果必須精準;測試該失敗的時候一定要失敗!
測試私有或者受保護的方法
解決思路:
- 將方法變成公共方法;
- 將方法抽取到新類;
- 將方法變成靜態方法;
- 將方法成為測試可見方法;
避免強制的測試順序
依賴測試順序導致測試可靠性變得脆弱,未來維護成本變高;
清理測試環境
在teardown階段清理測試環境,例如還原全局的Config、清理創建的文件目錄等等;
統一的單測命名、變量命名
統一的單測命名可以提高可讀性、可維護性;
使用有意義的斷言
斷言的錯誤信息要有意義,出現問題能夠明確錯誤的原因;
把單元測試視為“一等公民”
測試用例應該被視為“一等公民”:同樣需要代碼評審,同樣需要代碼質量檢查,確保單元測試的有效性;單元測試代碼評審的過程,也是團隊同學互相學習的過程,沉淀最佳實踐的過程。
加速執行速度
日常對單測執行時間進行監控,對測試進行性能分析,優化執行時間過長的測試用例。
測試金字塔
測試金字塔是Mike Cohn在他的著作《Succeeding with Agile》一書中提出了這個概念。測試金字塔是一個比喻,它告訴我們要把軟件測試按照不同粒度來分組。它也告訴我們每個組應該有多少測試。
為了維持金字塔形狀,一個健康、快速、可維護的測試組合應該是這樣的:寫許多小而快的單元測試。適當寫一些更粗粒度的測試,寫很少高層次的端到端測試。注意不要讓你的測試變成冰淇淋或者沙漏那樣子,這對維護來說將是一個噩夢,并且跑一遍也需要太多時間。
避免測試重復
在實現測試金字塔時,你也應該牢記這兩條基本法則:
如果一個更高層級的測試發現了一個錯誤,并且底層測試全都通過了,那么你應該寫一個低層級測試去覆蓋這個錯誤;
竭盡所能把測試往金字塔下層趕;
如果你已經在低層級測試里覆蓋了所有情況,那么再維護一個高層級的測試就沒有必要了。警惕沉沒成本的思維陷阱,果斷摁下刪除鍵。沒有理由在不再提供價值的測試上浪費寶貴時間。
補充單元測試應該從哪里開始
單元測試應該及時編寫,就算沒有實踐TDD,也應該在代碼實現之后盡快編寫單元測試,避免寫出不可測試的代碼,也可以讓bug盡早暴露;但很不幸的,我們很多時候在剛開始卓越工程,推廣單元測試的時候,不得不面對補充單元測試的情況;這絕對是一個有挑戰的事情。補充單元測試應該從哪里開始?參考測試金字塔,對于基礎組件庫來說,可以根據具體情況來定;對于業務庫來說,第一步建議從金字塔頂端的測試:
- 優先覆蓋回歸測試用例中P0級別的用例;
- 避免過度指定的端到端測試;
- 適當的契約測試;
接下來,從金字塔中間層開始,不斷向上、向下補充;
可測試的設計
應當容易、快速地為一段代碼編寫單元測試;可測試的設計,使我們寫出模塊化的設計;
行動指南
為了寫出可測試的代碼,需要注意以下幾點:
- 避免復雜的私有方法;
- 避免final方法;
- 避免static方法;
- 使用new要當心;
- 避免構造函數中包含邏輯;
- 避免單例;
- 組合優于繼承;
- 避免服務查找;
- 基于接口的設計;
可測試的代碼是否違背了SOLID中的開閉原則?
可測試的代碼設計,有的時候需要避免復雜的私有方法或者受保護的方法,因為這些意味著不可測試;這樣的話是不是意味著可測試的設計違反了開閉原則呢?在代碼重構的時候,可以認為給對象模型增加了另外一種最終用戶——測試用戶。另外如果一部分代碼實在不希望暴露,也可以使用@visibleForTesting 修飾;
單元測試與重構
寫單元測試的過程往往伴隨著重構;代碼重構同樣需要單元測試保證代碼正確運行。重構需要遵守的紀律:無測試重構無意義,頻繁重構、果斷重構、堅決重構;
持續重構
將麻煩扼殺在搖籃;
果斷重構
敏捷編程的名言之一。規則很簡單:重構時要勇敢。勇敢嘗試,勇敢修改,不用害怕代碼。
讓測試始終能通過
建一個綠色安全區,不允許破窗出現。
留條出路
倉庫打好tag,以便在需要的時候能夠回滾。
可測試的代碼
可測試的代碼就是解耦了的代碼;可測試的代碼幫助我們實現更好的抽象。
做不到TDD,可以做到測試先行
下圖是遵循TDD三大法則的實踐過程;TDD很強大,但不一定適用所有的團隊,推廣難度很大,學習曲線很高。
TDD事實上由兩個方面組成:測試先行,以及演進式設計;測試先行是非常重要的工程實踐,做不到TDD,可以做到測試先行。在Kent Beck的經典名著《解析極限編程》中,提到:盡早測試,經常測試,自動測試!測試先行的本質能力要求是接口的設計能力——能否清晰的定義出設計單元的邊界。
如何理解單元測試代碼覆蓋率
不要把它們變成管理的指標。這就是你使用覆蓋率數字的目的:使用它們作為衡量標準來幫助你改進,而不是用它們作為懲罰團隊和使構建失敗的棍棒。 ——《匠藝整潔之道》
代碼覆蓋率的一大忌諱:為了追求代碼覆蓋率,只測試不進行驗證;一味追求代碼覆蓋率,往往寫出無效的單元測試,額外增加了維護成本,最終不得不放棄以失敗告終。與其追求代碼覆蓋率,不如將重點關注在確保寫出有意義的測試。
沉淀最佳實踐
必須承認單元測試有一定的成本,成本曲線來看,前期比較高;恰恰是這前期的門檻,讓很多人望而卻步。在團隊內推廣的時候,最難的就是寫出第一個單元測試;我們需要沉淀最佳實踐,幫助降低寫單元測試的成本,讓我們更容易地寫出有效的單元測試。我覺得沉淀最佳實踐最好的方法,就是Code Review;正如我們前面所說的,要把單元測試當成是“一定公民”,在Code Review的過程中,互相學習、分享最佳實踐,消除無效的單元測試。
隔離單元測試與集成測試
集成測試是對一個工作單元進行的測試,這個測試對被測試的工作單元沒有完全的控制,并使用該工作單元一個或多個真實依賴,例如時間、網絡、數據庫、線程或隨機數生成器等。
任何測試,如果它的運行速度不快,結果不穩定,或者要用到被測試單元的一個或多個真實依賴,就是集成測試。在日常開發過程,我們需要建一個綠色安全區:單元測試與集成測試隔離;集成測試不夠穩定,運行時間長等問題,如果不做隔離,日常開發浪費時間和精力維護,最后導致開發人員不再信任測試。
單元測試與ABTest
單元測試與ABTest有什么關系嗎?事實上沒有什么關系。但一定程度程度上,它們本質是相同的,都是保障線上代碼質量(當然單測的成本,對于基建、開發者的能力的要求更高);在日常開發中,經常主動為新的代碼邏輯增加AB開關,一旦線上出問題留一條后路;發生問題的時候往往感慨AB開關救我一命;單元測試可以讓問題左移,防止問題上線,同樣是一道保護;如果有一天團隊同學愿意主動增加單元測試來保護自己的代碼,那么單元測試這件事就算比較成功了。
寫在最后
從軟件工程到卓越工程,單元測試從可選變成了必要;想要實現主干開發、大庫模式,單元測試是前提條件。關于單元測試這件事,我覺得最重要永遠是寫單元測試的人,優秀的團隊文化非常重要,沒有什么能夠真正衡量單元測試做的好壞,有的只是程序員的職業操守。我們花了很大的篇幅討論有效單元測試的重要性以及如何寫出有效的單元測試,不得不承認單元測試有一定的成本,真正實踐依然需要很多的路要走,需要我們在實踐中定義好單元測試的邊界,找到最適合團隊的最佳實踐。
參考文檔
- 《單元測試的藝術》
- 《有效的單元測試》
- 《Succeeding with Agile》
- 《匠藝整潔之道》
- The Test Pyramid:https://martinfowler.com/articles/practical-test-pyramid.html
- Software Engineering at Google:https://qiangmzsx.github.io/Software-Engineering-at-Google/#/zh-cn/Chapter-12_Unit_Testing/Chapter-12_Unit_Testing