單元測試難?來試試這些套路
測試不應(yīng)該是一門很高大尚的技術(shù),應(yīng)該是我們技術(shù)人的基本功。但現(xiàn)在好像慢慢地,單元測試已經(jīng)脫離了基本功的范疇。筆者曾經(jīng)在不同團隊中推過單元測試,要求過覆蓋率,但發(fā)現(xiàn)實施下去很難。后來在不停地刻意練習(xí)后,發(fā)現(xiàn)阻礙寫UT的只是筆者的心魔,并不是時間和項目的問題。在經(jīng)過一些項目的實踐后,也是有了一些自己的理解和實踐,希望和大家分享一下,和大家探討下如何克服“單元測試”的心魔。
內(nèi)功
前人們在單元測試方面的研究很多,有很多的方法論,我們可以拿來即用。我簡單介紹兩個方法論,一個概念。希望大家可以查閱更多的資料,凝聚自己的內(nèi)功心法。
TDD
Test Driven Development,也被認(rèn)為是Test Driven Design,我們這里按第一種定義來聊。TDD一改以往的破壞性測試的思維方式,測試在先、編碼在后,更符合“缺陷預(yù)防”的思想。簡單來說,TDD的流程是“紅-綠-重構(gòu)”三個步驟的循環(huán)往復(fù)。
- 紅:測試先行,現(xiàn)在還沒有任何實現(xiàn),跑UT的時候肯定不過,測試狀態(tài)是紅燈。編譯失敗也屬于“紅”的一種情況。
 - 綠:當(dāng)我們用最快,最簡單的方式先實現(xiàn),然后跑一遍UT,測試會通過,變成“綠”的狀態(tài)。
 - 重構(gòu):看一下系統(tǒng)中有沒有要重構(gòu)的點,重構(gòu)完,一定要保證測試是“綠”的。
 
業(yè)界有很多TDD的呼聲,也有TDD已死的文章。方法本來沒有對錯,只有優(yōu)劣,我們要辯證地來看。只能說TDD不是一個銀彈,不能解決所有問題。以筆者自己的經(jīng)驗,TDD比較適用于輸入輸出很明確的CASE,很多時候我們在摸索一種新的模式的時候,可能并不太適用。
如果你和前端已經(jīng)商議好了接口的出參、入?yún)?,可以嘗試一下TDD,一種新的思路,新的思想。
BDD
嚴(yán)格來說BDD是TDD衍生出來的一個小分支。但也可以用于一些不同維度的東西。概念大家自行尋找資料。這里講一下BDD的一點實踐經(jīng)驗。直接上代碼:
第一個UT是以方法維度,把所有場景放到一個方法來測試。
第二個UT是以case為角度,針對每個case單獨的測試。
其實TDD里面有一個概念是隔離性,單元測試之間應(yīng)該隔離開,不要互相干擾。另外,從命名上,第二種也更好一點。我個人還是比較推薦以下命名方式的:
- should:返回值,應(yīng)該產(chǎn)生的結(jié)果
 - when:哪個方法
 - given:哪個場景
 
另外BDD或者TDD中也有Task的概念,寫代碼之前先準(zhǔn)備好case。大家可以看一些BDD的文章,自己體會。如果對這個感興趣,可以在評論區(qū)探討。
測試金字塔
?? 
上圖來自martin fowler博客的TestPyramid[1]一文,也可以讀一下《Practical Test Pyramid》[2]。特別棒的文章,希望大家可以去讀一讀。
上面的金字塔的意思是,從Unit到Service,再到UI,速度越來越慢,成本也越來越高。
我們可以從服務(wù)端的角度把這三層稍微改一下:
- 契約測試:測試服務(wù)與服務(wù)之間的契約,接口保證。代價最高,測試速度最慢。
 - 集成測試(Integration):集成當(dāng)前spring容器、中間件等,對服務(wù)內(nèi)的接口,或者其他依賴于環(huán)境的方法的測試。
 
單元測試(Unit Test):純函數(shù),方法的測試,不依賴于spring容器,也不依賴于其他的環(huán)境。
?? 
我們現(xiàn)在寫測試,一般是單元測試和集成測試兩層。針對具體場景,選擇適合自己的測試粒度。
招數(shù)
其實寫單元測試是有一些招數(shù)的,下面會介紹筆者很喜歡的一種單元測試代碼組織結(jié)構(gòu),也會介紹一些常用的招數(shù),以及使用場景。
常見問題
- 一個類里面測試太多怎么辦?
 - 不知道別人mock了哪些數(shù)據(jù)怎么辦?
 - 測試結(jié)構(gòu)太復(fù)雜?
 - 測試莫名奇妙起不來?
 
Fixture-Scenario-Case
FSC(Fixture-Scenario-Case)是一種組織測試代碼的方法,目標(biāo)是盡量將一些MOCK信息在不同的測試中共享。其結(jié)構(gòu)如下:
?? 
- 通過組合Fixture(固定設(shè)施),來構(gòu)造一個Scenario(場景)。
 - 通過組合Scenario(場景)+ Fixture(固定設(shè)施),構(gòu)造一個case(用例)。
 
下面是一個FSC的示例:
?? 
- Case:當(dāng)用戶正常登錄后,獲取當(dāng)前登錄信息時,應(yīng)該返回正確的用戶信息。這是一個簡單的用戶登錄的case,這個case里面總共有兩個動作、場景,一個是用戶正常登錄,一個是獲取用戶信息,演化為兩個scenario。
 - Scenario:用戶正常登錄,肯定需要登錄參數(shù),如:手機號、驗證碼等,另外隱含著數(shù)據(jù)庫中應(yīng)該有一個對應(yīng)的用戶,如果登錄時需要與第三方系統(tǒng)進行交互,還需要對第三方系統(tǒng)進行mock或者stub。獲取用戶信息時,肯定需要上一階段頒發(fā)的憑證信息,另外該憑證可能是存儲于一些緩存系統(tǒng)的,所以還需要對中間件進行mock或者stub。
 - Fixture
 
- 利用Builder模式構(gòu)造請求參數(shù)。
 - 利用DataFile來存儲構(gòu)造用戶的信息,例如DB transaction進行數(shù)據(jù)的存儲和隔離。
 - 利用Mockito進行三方系統(tǒng)、中間件的Mock。
 
當(dāng)這樣組織測試時,如果另外一個Case中需要用戶登錄,則可以直接復(fù)用用戶登錄的Scenario。也可以通過復(fù)用Fixture來減少數(shù)據(jù)的Mock。下面我們來詳細(xì)解釋看一下每一層如何實現(xiàn),show the code。
Case
case是用例的意思,在這里用例是場景和一些固定設(shè)施的組合。這里要注意的是,盡量不要直接修改接口的數(shù)據(jù),一個場景所依賴的環(huán)境應(yīng)該是另一個場景的輸出。當(dāng)然有些特定場景下,還是需要直接改數(shù)據(jù)的,這里不是禁止,而是建議。
Scenario
JUNIT的用法就不說了,相信大家都了解,這里提兩個框架REST Assured和Mock MVC。這兩個框架都可以用來做接口測試,Mock MVC是spring原生的,可以指定加載的Resource,一定程度上可以提升UT速度,但是和spring是耦合在一起的。REST Assured是脫離Spring的,可以理解為利用http進行接口的測試,耦合性更低,使用靈活。兩者各有千秋,筆者比較推薦REST Assured。我們看一下,一個REST Assured打造的Scenario怎么寫,怎么用?
Fixture
固定設(shè)施部分,主要是用來提供一些固定的組件和數(shù)據(jù)。盡量的讓這部分東西有復(fù)用性,如果沒復(fù)用性,盡量和測試放在一起,不要干擾他人。
(1)方法
(a)Mock
mockito挺通用的,而且spring也提供了@MockBean,可以直接將Mock一個bean放入spring的容器中。然后可以利用mockito提供的方法對方法進行模擬或者驗證。代碼示例:
(b)stub
stub是打樁,關(guān)于打樁和mock的區(qū)別,請自行百度,這里只是想展示一下,在spring的環(huán)境下,覆蓋原有bean達(dá)到stub的效果。
(c)嵌入式DB
這里簡單介紹幾種嵌入式DB,可以自行選擇使用。
?? 
(d)直連DB + Transaction
- 除了使用嵌入式的DB,也可以直連環(huán)境,但不推薦,因為環(huán)境上的數(shù)據(jù)是多變的,如果測試出現(xiàn)問題,排查的復(fù)雜度會增加。這里其實想強調(diào)下@Transactional。因為Mock的數(shù)據(jù)最好做到隔離,比如一個接口的操作是批量刪除數(shù)據(jù),有可能會把一個其他測試依賴的數(shù)據(jù)刪除掉,這樣問題一旦出現(xiàn)很難排查,因為單獨跑每個測試都是通過的,但是一起跑就會出問題。這里推薦兩種做法:
 - 使用@Transactional在一些測試的類上,這樣在跑完測試后,數(shù)據(jù)不會commit,會回滾。但如果測試中對事物的傳播有特殊要求,可能不適用。
 
通用的trancateAll和initSQL通過在每個測試前跑清除數(shù)據(jù)、mock數(shù)據(jù)的腳本,來達(dá)到每個測試對應(yīng)一個隔離環(huán)境,這樣數(shù)據(jù)間就不會產(chǎn)生干擾。
(e)PowerMock
PowerMock是用來創(chuàng)建一些靜態(tài)方法的Mock的,如果你的代碼中會調(diào)用一些靜態(tài)方法,但是靜態(tài)方法依賴于一些其他復(fù)雜的邏輯或者資源??梢允褂眠@個包。
注意:
- PowerMock不僅僅是用來mock靜態(tài)方法的。
 - 不建議mock靜態(tài)方法,因為靜態(tài)方法的使用場景都是些純函數(shù),大部分的純函數(shù)不需要mock。部分靜態(tài)方法依賴于一些環(huán)境和數(shù)據(jù),針對這些方法,需要考慮下到底是要mock其依賴的數(shù)據(jù)和方法,還是真的要mock這個函數(shù),因為一旦mock了這個函數(shù),意味著隱藏了細(xì)節(jié)。
 
(2)數(shù)據(jù)
(a)Builder模式
數(shù)據(jù)最簡單的mock方式就是Builder,然后自己手填各種參數(shù),但有些對象有幾十個字段,而你的一個測試只需要改其中的兩個字段,你該怎么辦?Copy、Paste?
(b)數(shù)據(jù)文件
有時候通過builder構(gòu)造對象的時候,字段太多,并且數(shù)據(jù)的來源是前端或者其他服務(wù)提供的json。這個時候可以將這個數(shù)據(jù)存儲到文件中,利用一些工具方法,將數(shù)據(jù)讀取成制定的文件。這也是數(shù)據(jù)mock的常用手段。我這里是以json為例,其實sql等數(shù)據(jù)也可以這樣。
數(shù)據(jù)文件的優(yōu)點:可承載的數(shù)據(jù)量大、編輯方便。
使用場景
在筆者的實踐中, 目前主要把FSC是用在接口測試上,也就是測試金字塔的Integration Test部分,放在這個層次,有幾個原因:
- FSC本身會給測試帶來復(fù)雜度,而UnitTest應(yīng)該簡單,如果UnitTest本身都很復(fù)雜了,項目帶來難以估量的測試成本。
 - Fixture其實可以在任何場景中使用,因為是底層的復(fù)用。
 
缺陷
- 增加了代碼復(fù)雜度。
 - 通過IDE工具無法直接定位的測試文件,折衷的方案是case的命名符合ResouceTest的命名。
 
校場
從簡單到復(fù)雜
上面我們介紹了測試金字塔,越靠上層,復(fù)雜度越高。所以剛接觸單元測試的同學(xué),可以從“單元測試”的層次開始練習(xí),可以練習(xí)Builder,F(xiàn)ixture怎么寫,方法怎么Mock。如果你感覺這些都到了拿來即用的階段,那就可以往上層寫,考慮下怎么給項目增加一些通用的基礎(chǔ)設(shè)施,來減少測試的整體復(fù)雜度。
刻意練習(xí):3F原則
刻意練習(xí),簡而言之,就是刻意的練習(xí),它突出的是有目的的練習(xí)??桃饩毩?xí)也有它的一整套過程,在這個過程里,你需要遵守它的3F法則:
- 第一,F(xiàn)ocus(保持專注)。
 - 第二,F(xiàn)eedback(注重反饋,收集信息)。
 - 第三,F(xiàn)ix it(糾正錯誤,并且進行修改)。
 
UT本身是一項技術(shù),是需要我們打磨、練習(xí)的,最好的練習(xí)方式,就是刻意練習(xí),如果有決心,一個周末在家刻意練習(xí),為項目中的部分場景加上UT,相信收獲會很豐富。
打造自己的測試環(huán)境
自己要不斷的摸索,什么樣的組織方式,什么樣的工具方法是適合自己項目的。軟件工程中沒有銀彈,沒有最好,只有合適。
常見問題
- 應(yīng)不應(yīng)該連日常環(huán)境進行測試?
 
- 個人不建議直接連日常環(huán)境進行測試,如果兩個人同時在跑測試,那么很有可能測試環(huán)境的數(shù)據(jù)會處于混亂狀態(tài)。而且UT盡可能不要依賴過多的外部環(huán)境,依賴越多越復(fù)雜。測試還是簡單點好。
 
- 一個類里面測試太多怎么辦?
 
- 考慮按測試的case區(qū)分,也可按測試的方法區(qū)分,也可以按正常、異常場景區(qū)分。
 
- 不知道別人mock了哪些數(shù)據(jù)怎么辦?
 
- 盡量讓大家Mock數(shù)據(jù)的命名規(guī)范,通過Fixutre的復(fù)用,來減少新寫測試的成本。
 
- 測試結(jié)構(gòu)太復(fù)雜?
 
- 考慮是不是自己應(yīng)用的代碼組織就有問題?
 
- 測試莫名奇妙起不來?
 
- 需要詳細(xì)了解JUNIT、Spring、PandoraBoot等是如何進行測試環(huán)境的mock的,是不是測試間的數(shù)據(jù)沖突等。詳細(xì)的我們會在方法篇持續(xù)更新,遇到問題解決問題。
 
心魔
單元測試這件事,實施的時候還是有很多阻力的,筆者原來給自己也找過很多理由,無論是用來說服領(lǐng)導(dǎo)的,還是說服自己的。下面是筆者對于這些理由的一些思考,希望能和大家有一些共鳴。
不會寫
雖然很不愿意承認(rèn)這個事,但最后還是承認(rèn)了自己是真的不會寫單元測試。剛接觸單元測試的時候,看了看junit的文檔,心想單元測試,不就是個“Assert”嗎,有啥不會的,這東西好學(xué)。后來實施過程中發(fā)現(xiàn),單元測試不僅僅是“Assert”,還需要準(zhǔn)備環(huán)境,Mock數(shù)據(jù),復(fù)現(xiàn)場景,驗證。著實是個麻煩事。
后來反思,為什么單元測試麻煩?一開始學(xué)習(xí)ORM框架的時候不麻煩嗎?一開始學(xué)Spring不麻煩嗎?后來熟悉了Bean的生命周期、BeanFactory、BeanProcessor等,Spring已經(jīng)不是個麻煩事了。仔細(xì)想想,自己對單元測試的理解僅僅是:“一個Mock加一個Assert”。僅僅學(xué)了幾個框架,看了幾篇文章,還做不到把單元測試這件事真正落地。
在落地單元測試的時候,有一些常見的問題:
場景太復(fù)雜,需要的數(shù)據(jù)太多,怎么處理?
可以直接使用JSON、SQL將現(xiàn)有數(shù)據(jù)修改后導(dǎo)入到系統(tǒng)中。這樣的話可能需要mock的數(shù)據(jù)就不會那么多了,可以提煉一些工具類,直接從resource中讀取數(shù)據(jù)文件,導(dǎo)入到數(shù)據(jù)庫、或者提供給mock方法使用。
也可以構(gòu)建一些Fixture,將自己系統(tǒng)中UT的數(shù)據(jù)固定下來,這樣,如果前面一個同學(xué)已經(jīng)mock過相關(guān)數(shù)據(jù)了,再新寫UT的時候可以拿來即用。構(gòu)建Fixture可以用工廠模式、構(gòu)建者模式等來達(dá)到數(shù)據(jù)隔離的效果,避免相互干擾。
好多東西都是和中間件或者其他系統(tǒng)頻繁交互,怎么寫測試?
數(shù)據(jù)庫層面可以使用內(nèi)存型數(shù)據(jù)庫“H2”、"Embedded Mysql"、“Embedded PostgreSql”等。
如果以上都不能解決問題,可以使用mockito直接mock相應(yīng)的Bean。
單元測試的粒度問題,這個方法該不該寫UT,另外一個方法為什么不需要寫UT?
單元測試的粒度沒有標(biāo)準(zhǔn)答案,筆者自己總結(jié)了一些寫UT粒度方面的方法:
- 不熟悉單元測試寫法,盡量寫簡單的單元測試,覆蓋核心方法。
 - 熟悉單元測試,業(yè)務(wù)復(fù)雜,覆蓋正常、一般異常場景,另外對核心業(yè)務(wù)邏輯要有單獨的測試。
 
測試如何復(fù)用?
測試應(yīng)該是有組織、有結(jié)構(gòu)的,就像我們寫業(yè)務(wù)代碼一樣,會想著如何在代碼層面復(fù)用、如何在功能層面復(fù)用、如何在業(yè)務(wù)維度復(fù)用。單元測試也應(yīng)該有結(jié)構(gòu),可以盡量復(fù)用一些前人的經(jīng)驗。簡單來說,測試的復(fù)用也分為三個維度:數(shù)據(jù)、場景、用例,好的代碼結(jié)構(gòu)應(yīng)該盡量的能讓測試復(fù)用,讓增加UT不再是從頭開始。
不想寫
寫測試有什么用?
很多人都寫過單元測試的文章,羅列過很多單元測試的很多好處,這里就不贅述了。這里講幾個感觸比較深的用處吧?
- DEBUG:阿里現(xiàn)在的基礎(chǔ)設(shè)施是真的完善,中間件、各種監(jiān)控、日志,只要系統(tǒng)埋點夠好,遇到的很多問題都可以解決,即使有一些復(fù)雜問題,也可以local debug。但在一些特殊場景下,將數(shù)據(jù)MOCK好,利用UT來DEBUG,可能效率更高,大家可以試試。
 - 測試如文檔:我們現(xiàn)在開發(fā)有很多完善的文檔,但文檔這東西和代碼上畢竟有一層映射關(guān)系,如果能快速了解業(yè)務(wù),完善的測試,有時候也是個不錯的選擇,例如大家學(xué)習(xí)一些開源框架的時候,都會從測試開始看。
 - 重構(gòu):當(dāng)你想下定決心重構(gòu)的時候,才發(fā)現(xiàn)項目中沒有單元測試,什么心情?
 
價值不高
在面對復(fù)雜的接口時,常常需要Mock很多數(shù)據(jù)來支撐一個小的點,很多時候內(nèi)心感覺沒價值,因為一個if-else的變動,竟然需要準(zhǔn)備N份數(shù)據(jù),得不償失。
后來反思,為什么一個if-else的變動,需要準(zhǔn)備N份數(shù)據(jù)?如果這個接口一開始寫的時候就有健全的UT,那一個if-else的變更還需要準(zhǔn)備N份數(shù)據(jù)嗎?大概率不需要了吧,有可能只需要改一個測試case就好了。所以說現(xiàn)在成本高,將來成本會更高,現(xiàn)在做了,做的好一點,后面可能成本就低了。
筆者觀點:寫單元測試,應(yīng)該比寫代碼的成本更低。
懶
這個不用說吧,通用理由,大家都明白。路是人踩出來的,總要有人要先走。Why not you?
最后
如果大家對于單元測試有好的實踐,或者對文章中的一些觀點有些共鳴,大家可以在評論區(qū)留言,我們互相學(xué)習(xí)一下。大家也可以在評論區(qū)寫出自己的場景,大家一起探討如何針對特定場景來實踐。
相關(guān)鏈接
[1]https://martinfowler.com/bliki/TestPyramid.html
[2]https://martinfowler.com/articles/practical-test-pyramid.html
【本文為51CTO專欄作者“阿里巴巴官方技術(shù)”原創(chuàng)稿件,轉(zhuǎn)載請聯(lián)系原作者】















 
 
 




 
 
 
 