作為一名程序員,單元測(cè)試應(yīng)該聽(tīng)過(guò),但是很多同學(xué)沒(méi)有用的過(guò),可能是對(duì)單元測(cè)試有一些誤解,例如:
- 寫(xiě)單元測(cè)試需要花費(fèi)更多的時(shí)間,我每天寫(xiě)產(chǎn)品代碼都要加班,哪來(lái)時(shí)間寫(xiě)測(cè)試;
- 寫(xiě)單元測(cè)試收益不大,還不是一樣有bug;
- 寫(xiě)單元測(cè)試有負(fù)擔(dān),改產(chǎn)品代碼的結(jié)構(gòu),還得去改測(cè)試代碼。
先嘗試解答這幾個(gè)問(wèn)題。
寫(xiě)單元測(cè)試會(huì)花費(fèi)更多的時(shí)間,這點(diǎn)描述其實(shí)不準(zhǔn)確。準(zhǔn)確地說(shuō),寫(xiě)單元測(cè)試需要花費(fèi)更多「寫(xiě)代碼的時(shí)間」,這點(diǎn)沒(méi)什么可說(shuō)的,畢竟要多寫(xiě)一些測(cè)試代碼。但一個(gè)程序員,做一個(gè)需求的時(shí)候,花在純寫(xiě)代碼的時(shí)間其實(shí)不多。你得理以前代碼的邏輯,設(shè)計(jì)類(lèi)和方法,然后才是寫(xiě)代碼,寫(xiě)完了再手動(dòng)測(cè)試,可能有bug
還要去debug
,再修復(fù),再測(cè)試?!?strong>真正寫(xiě)代碼的時(shí)間,其實(shí)是很少的」。使用單元測(cè)試雖然可能占用了更多寫(xiě)代碼的時(shí)間,但它可以幫你縮短其它時(shí)間,「會(huì)讓你做這個(gè)需求花費(fèi)的總時(shí)間更少」。
寫(xiě)單元測(cè)試會(huì)有bug
嗎?當(dāng)然可能有了,我們是無(wú)法做到真正的bug free
的,但是單元測(cè)試寫(xiě)好了,可以顯著減小bug
的數(shù)量。因?yàn)閷?xiě)單元測(cè)試發(fā)現(xiàn)bug
的成本是非常低的,它可以在開(kāi)發(fā)階段就發(fā)現(xiàn)bug
,而且可以測(cè)試很多邊界的條件。但如果你「對(duì)需求和業(yè)務(wù)的認(rèn)知本來(lái)都是有誤」的,這是單元測(cè)試解決不了的,自然會(huì)產(chǎn)生bug
。話說(shuō)回來(lái),寫(xiě)單元測(cè)試的收益遠(yuǎn)不止發(fā)現(xiàn)bug
這么簡(jiǎn)單,它還具有「代碼文檔」的功能,以及「重構(gòu)的安全網(wǎng)」存在。甚至它還可以幫你「理需求」,「設(shè)計(jì)代碼」。
改產(chǎn)品代碼需要維護(hù)對(duì)應(yīng)的測(cè)試代碼,這確實(shí)是帶來(lái)了額外的成本。不過(guò)借助編輯器的「重構(gòu)功能」,可以比較方便地批量修改需要修改的地方,其實(shí)代價(jià)沒(méi)有想象中的那么大。而且重構(gòu)后,再跑一遍單元測(cè)試,看哪些掛掉了,可以double-check
你改的產(chǎn)品代碼有沒(méi)有問(wèn)題。如果我們把單元測(cè)試當(dāng)成是「產(chǎn)品代碼的文檔」來(lái)看,大概就更能夠接受這個(gè)維護(hù)的成本了。
為什么需要單元測(cè)試?
前面提到,單元測(cè)試有很多功能。個(gè)人覺(jué)得單元測(cè)試最大的作用是“代碼文檔”和“重構(gòu)安全網(wǎng)”。畢竟軟件開(kāi)發(fā)的漫長(zhǎng)過(guò)程中,總少不了修修改改。如果沒(méi)有足夠的測(cè)試,改一段代碼就像是在排地雷,改完后心里也總是打鼓,上線前需要先默默拜個(gè)神,生怕觸發(fā)了什么bug
。
但如果有足夠的測(cè)試(不只是單元測(cè)試),改完代碼后可以跑一遍測(cè)試,看哪些掛掉了,是不是自己的改動(dòng)導(dǎo)致的,該怎么修好測(cè)試。這樣心里就有底氣多了。
要知道,代碼寫(xiě)出來(lái)是給人看的。而測(cè)試比產(chǎn)品代碼更友好,因?yàn)樗?jiǎn)單,直白,站在使用者的視角來(lái)描述,所以如果想要了解一段產(chǎn)品代碼具有什么功能,看它的單元測(cè)試會(huì)更直觀,更舒服。
很多團(tuán)隊(duì)會(huì)做測(cè)試,但絕大多數(shù)測(cè)試的工作是在開(kāi)發(fā)后,由專(zhuān)門(mén)的測(cè)試同學(xué)去負(fù)責(zé)端到端的測(cè)試或者API
測(cè)試。其實(shí)端到端的測(cè)試成本是非常大的,尤其是對(duì)于某些邊界條件,構(gòu)造數(shù)據(jù)和場(chǎng)景是非常麻煩的。而且一旦發(fā)現(xiàn)了bug
,再去溝通,修改,提交,部署,需要花費(fèi)很多時(shí)間。
而單元測(cè)試最大的優(yōu)勢(shì)就是“成本低”,想要測(cè)試產(chǎn)品代碼的每個(gè)分支都比較容易,而且單元測(cè)試一般是開(kāi)發(fā)同學(xué)自己寫(xiě),可以用最小的時(shí)間發(fā)現(xiàn)bug
,用最低的成本修改bug
。
什么是單元測(cè)試?
測(cè)試金字塔
并不是所有測(cè)試都是單元測(cè)試,測(cè)試其實(shí)分成很多種。業(yè)界比較廣泛傳播的“測(cè)試金字塔”描述了它們的區(qū)別和關(guān)系:
從測(cè)試金字塔模型來(lái)看,越在底層的測(cè)試,覆蓋面應(yīng)該更廣,成本更低。單元測(cè)試處于測(cè)試金字塔的最低端,是整個(gè)測(cè)試金字塔的基礎(chǔ)。
當(dāng)然了,測(cè)試金字塔并不一定只有三層,中間可能會(huì)有其它的測(cè)試,比如“契約測(cè)試”等。
單元測(cè)試的特點(diǎn)
單元測(cè)試就像它的名字一樣,“單元”(Unit),足夠小,足夠快,無(wú)依賴。單元測(cè)試只測(cè)你想測(cè)的那部分產(chǎn)品代碼的邏輯,一個(gè)單元測(cè)試應(yīng)該只測(cè)一個(gè)簡(jiǎn)單的業(yè)務(wù)邏輯。一般來(lái)說(shuō),運(yùn)行一個(gè)單元測(cè)試是很快的,基本上在幾毫秒到幾十毫秒之間。如果有依賴的類(lèi),可以mock
其他類(lèi),消除外部依賴。
什么不是單元測(cè)試?
很多同學(xué)容易將其他測(cè)試與單元測(cè)試搞混,最常見(jiàn)的是會(huì)啟動(dòng)Spring
上下文的集成測(cè)試。比如使用@SpringBootTest
注解可以啟動(dòng)Spring
上下文,這可以測(cè)試依賴是否正常注入等Spring
的功能,但運(yùn)行一次需要耗費(fèi)很多時(shí)間(因?yàn)橐獑?dòng)Spring上下文),也并不是真正的“單元測(cè)試”,因?yàn)樗蕾嚵?code>Spring框架。
如何寫(xiě)單元測(cè)試
那具體如何寫(xiě)單元測(cè)試呢?我們業(yè)界有一個(gè)叫做「TDD」(測(cè)試驅(qū)動(dòng)開(kāi)發(fā))的方法論。TDD
的核心在于“驅(qū)動(dòng)”二字,它的理念是從測(cè)試視角出發(fā),通過(guò)測(cè)試驅(qū)動(dòng)出來(lái)產(chǎn)品代碼。而在測(cè)試金字塔中,單元測(cè)試與開(kāi)發(fā)人員最息息相關(guān),所以這里的“測(cè)試”一般是指的單元測(cè)試。
TDD大概分這幾個(gè)步驟:
- 理清需求
- 設(shè)計(jì)類(lèi)和方法的出參和入?yún)?/li>
- 寫(xiě)測(cè)試代碼
- 驅(qū)動(dòng)出產(chǎn)品代碼
- 重構(gòu),循環(huán)3-5步。
首先要理清楚需求,因?yàn)橹挥欣砬宄诵枨?,才能保證我們使用TDD
驅(qū)動(dòng)出來(lái)的代碼是跟業(yè)務(wù)期望的一致的。然后第二步是設(shè)計(jì)類(lèi)和方法的過(guò)程,也稱為Task List
。這一步可以設(shè)計(jì)好類(lèi)與類(lèi)之間的關(guān)系,方法的出參和入?yún)?。其?shí)不使用TDD
也會(huì)有前面這兩個(gè)步驟,只不過(guò)使用TDD
的話,可以幫助你更好地從業(yè)務(wù)視角出發(fā),先把該設(shè)計(jì)的東西都設(shè)計(jì)好,避免直接上手寫(xiě)代碼,寫(xiě)到一半的時(shí)候覺(jué)得不對(duì),再去改。
3-5步其實(shí)是一個(gè)循環(huán)的過(guò)程。因?yàn)閯傞_(kāi)始寫(xiě)代碼可能并沒(méi)有太注意代碼的格式、風(fēng)格、性能,一氣呵成寫(xiě)得比較快,讓測(cè)試通過(guò)。等測(cè)試通過(guò)后,可以回過(guò)頭來(lái)重構(gòu)一下之前寫(xiě)的代碼,重構(gòu)后再跑一遍所有的單元測(cè)試,看是否有掛掉的單元測(cè)試,以此來(lái)檢測(cè)重構(gòu)是否對(duì)期望的輸入輸出有影響。
單元測(cè)試的結(jié)構(gòu)
一個(gè)完整的單元測(cè)試,應(yīng)該分為4個(gè)部分:
- 聲明和參數(shù)
- 準(zhǔn)備入?yún)⒑蚼ock
- 調(diào)用產(chǎn)品代碼
- 驗(yàn)證,也叫斷言
拿Java
來(lái)說(shuō),單元測(cè)試框架有幾個(gè),最流行的應(yīng)該是JUnit
和TestNG
。筆者使用JUnit
多一點(diǎn),JUnit
使用@Test
注解在方法上來(lái)聲明一個(gè)測(cè)試。JUnit
最新版本是JUnit 5
,JUnit 5
相較于上一個(gè)版本,在參數(shù)化測(cè)試方面做了很多改進(jìn),這樣我們就不用寫(xiě)很多個(gè)高度相似的測(cè)試方法了(關(guān)于JUnit 5參數(shù)化測(cè)試,大家可以查看官方文檔,也有對(duì)應(yīng)的中文翻譯,很方便閱讀)。
一般來(lái)說(shuō),方法名需要盡可能可讀,它可能比較長(zhǎng),但能夠清晰地表述這個(gè)測(cè)試的意圖,比如:
@Test void shouldReturn5WhenCalculateSumGiven2And3() {}
@Test
void should_return_5_when_calculate_sum_given_2_and_3() {}
具體使用駝峰命名法還是下劃線,根據(jù)自己團(tuán)隊(duì)的規(guī)范來(lái)就好,盡量所有測(cè)試風(fēng)格保持一致。(個(gè)人更喜歡下劃線~)
入?yún)⒁话闶腔绢?lèi)型或者POJO
對(duì)象,有些參數(shù)可以抽成變量,后面在驗(yàn)證階段可能用得上。
如果產(chǎn)品代碼有外部依賴,就需要用mock
來(lái)消除外部依賴。常見(jiàn)的Mock
框架有EasyMock
、「Mockito」等,大家可以對(duì)比一下各個(gè)mock
框架的區(qū)別,選擇一個(gè)合適的。
很多同學(xué)剛開(kāi)始寫(xiě)單元測(cè)試的時(shí)候不能理解為什么需要mock
,覺(jué)得mock
比較麻煩,甚至有點(diǎn)多此一舉的感覺(jué)。其實(shí)不然,mock
的意義在于,你「可以保證你的測(cè)試只測(cè)試了你要測(cè)的那部分代碼」。這樣如果測(cè)試不通過(guò),你就可以知道一定是要測(cè)的那個(gè)方法有問(wèn)題,不可能是外部依賴的問(wèn)題,這樣才能做到真正的“單元”化,才能保證每個(gè)測(cè)試足夠小,足夠純粹。
準(zhǔn)備好入?yún)⒑?code>mock后,會(huì)顯式地調(diào)用一下要測(cè)的那個(gè)方法,這個(gè)一般只有簡(jiǎn)單的一行。
最后是驗(yàn)證,驗(yàn)證分為好幾種,最常用的是驗(yàn)證出參是符合自己期望的。也有時(shí)候會(huì)驗(yàn)證異常等邊界情況。JUnit
等測(cè)試框架基本上自己帶了驗(yàn)證的功能,但API
都比較簡(jiǎn)單,個(gè)人感覺(jué)不是特別好用,推薦使用「AssertJ」,功能強(qiáng)大,API
用起來(lái)也比較舒服。
舉個(gè)例子吧:
@Test void shouldReturnUserWithOrgInfoWhenLoginWithUserId() { String userId = "userId"; String orgId = "orgId"; User user = UserFactory.getUser(userId); Org org = OrgFactory.getOrg(orgId); given(orgService.getOrgById(orgId)).willReturn(org);
UserInfo userInfo = userService.login(userId);
assertEquals(org, userInfo.getOrg());
}
單元測(cè)試常見(jiàn)問(wèn)題
下面聊一聊單元測(cè)試常見(jiàn)的一些問(wèn)題。
先寫(xiě)測(cè)試還是先寫(xiě)產(chǎn)品代碼?
都可以。雖然有一種說(shuō)法是TDD
推薦的是先寫(xiě)測(cè)試,再寫(xiě)實(shí)現(xiàn)。但很多剛開(kāi)始寫(xiě)單元測(cè)試的同學(xué)并不習(xí)慣這種方式。先寫(xiě)測(cè)試有一個(gè)好處,可以讓你在設(shè)計(jì)代碼的時(shí)候從業(yè)務(wù)視角去思考,而不是代碼實(shí)現(xiàn)視角。大家可以嘗試先寫(xiě)測(cè)試再寫(xiě)實(shí)現(xiàn),體會(huì)一下這種感覺(jué)。
寫(xiě)單元測(cè)試需要花費(fèi)大量額外的時(shí)間?
這個(gè)其實(shí)在文章開(kāi)篇已經(jīng)討論過(guò)了。寫(xiě)單元測(cè)試確實(shí)會(huì)花費(fèi)更多的“寫(xiě)代碼”的時(shí)間,但是總的來(lái)說(shuō),它可以縮短整個(gè)需求開(kāi)發(fā)周期的時(shí)間。所以寫(xiě)單元測(cè)試完全是一筆“劃算的生意”。
什么代碼最需要單元測(cè)試?
不自信的代碼,邏輯復(fù)雜的代碼,重要的代碼。比如工具類(lèi)、三層架構(gòu)的Service
層、DDD
的聚合根和領(lǐng)域服務(wù)等,這些都應(yīng)該寫(xiě)足夠的單元測(cè)試。
入?yún)?duì)象構(gòu)造太麻煩?
構(gòu)造一個(gè)合適的入?yún)?duì)象比較麻煩,尤其是有些對(duì)象有非常多的參數(shù),如果每個(gè)測(cè)試都要去從頭構(gòu)造的話,會(huì)讓測(cè)試代碼變得非常臃腫,可讀性變差。這個(gè)時(shí)候可以使用工廠類(lèi)來(lái)批量生產(chǎn)對(duì)象。這個(gè)工廠類(lèi)放在測(cè)試目錄下,并不會(huì)對(duì)生產(chǎn)代碼造成影響。前面的例子里面,UserFactory
就是一個(gè)User
對(duì)象的工廠類(lèi)。
返回值為void測(cè)什么?
返回值為void
,說(shuō)明方法沒(méi)有出參,那方法內(nèi)部必然有一些行為,它可能是「改變了內(nèi)部屬性的值」,也可能是「調(diào)用了某個(gè)外部類(lèi)的方法」。
如果是改變內(nèi)部的某個(gè)值,那可以通過(guò)對(duì)象的get
參數(shù)來(lái)斷言。這在使用DDD
后的領(lǐng)域模型是一個(gè)問(wèn)題,因?yàn)橛锌赡鼙緛?lái)產(chǎn)品代碼不需要暴露出get
方法的,但由于測(cè)試需要,暴露出了內(nèi)部屬性的get
方法。雖然使用反射也可以拿到內(nèi)部屬性的值,但沒(méi)有太大必要。權(quán)衡利弊,還是暴露領(lǐng)域模型的get
方法好一點(diǎn)。
如果是調(diào)用某個(gè)外部的方法,可以用verify
來(lái)驗(yàn)證是否調(diào)用了某個(gè)方法,可以用capture
驗(yàn)證調(diào)用其它方法的入?yún)?。這樣也可以驗(yàn)證產(chǎn)品代碼是否如自己預(yù)期的設(shè)計(jì)在工作。
static方法如何mock?
static
方法不好mock
,需要用特殊的mock
框架。比如PowerMock
、JMockit
。一般來(lái)說(shuō),Utils
類(lèi)的方法很多是static
的,我們用得很多的時(shí)間類(lèi)LocalDateTime
,獲取當(dāng)前時(shí)間,也是static
的。這個(gè)時(shí)候需要用專(zhuān)門(mén)的mock
框架來(lái)mock
一下。
多線程如何測(cè)試?
多線程也不好測(cè)試。如果程序簡(jiǎn)單,可以用「睡眠」或者CountDownLatch
等多線程工具類(lèi)來(lái)輔助測(cè)試,等所有線程跑完,再統(tǒng)一驗(yàn)證。
如果程序相對(duì)復(fù)雜,需要使用專(zhuān)門(mén)的多線程測(cè)試框架,比如tempus-fugit
、Thread Weaver
、MultithreadedTC
、以及OpenJDK
的jcstress
項(xiàng)目等。
關(guān)于具體的框架如何使用,以后有時(shí)間可以寫(xiě)一篇常用的注解的介紹。其實(shí)官方文檔里面都有寫(xiě),大家照著官網(wǎng)寫(xiě)幾個(gè)例子就會(huì)了。比較推薦的基礎(chǔ)套餐是junit 5 + mockito + assertj
。關(guān)于static
方法和多線程測(cè)試框架,大家有需要的時(shí)候再去了解也行。
想了解更多測(cè)試的看一下相關(guān)教程:
軟件測(cè)試:http://o2fo.com/software_testing/
文章參考來(lái)源:www.toutiao.com/a6856755990545891848/