到底什么是單元測試
這個問題看似非常簡單,單元測試嘛,不就是咱們開發自己寫些測試類,來測試自己寫的代碼邏輯對不對。
這句話沒有問題,但是不夠準確。
首先我們要明白,這個測試二字前面還有兩個字: 單元 。
它要求我們的測試粒度,小
具體來說就是一個 Test 僅測試一個方法,對這句話的認識非常重要。
市面上常見的錯誤單測是怎樣的呢:
把整個項目啟動,開始玩真的調用,入參是數據庫里面真的值,所有的操作都落庫,一個 Test 從 controller 到 service 再到 dao,一條龍打通。
這種不叫單元測試, 這叫集成測試 。
如果你現在寫的是這樣的“單測”,你就會發現,寫個測試類不僅要依賴數據庫,還要依賴緩存,依賴公司別的團隊的服務,亦或是一些三方開放平臺的 Http 服務。
當我們的測試類需要依賴太多太多外部因素的時候,只要有一個地方出現問題,你的測試就是 fail 的。
并且入參和出參不能“任你擺布”,你還得想著如何控制別的團隊的服務返回你想要的數據。
比如我想測試當依賴的服務 A 返回 sucess 時,我的代碼邏輯的正確性,還得想測試服務 A 返回 fail 的邏輯,還想測試它返回 null 的邏輯。
再包括數據庫或者緩存的一些返回值的定制,這非常的困難,已經開始勸退人了。
然后 把整個項目啟動 ,這通常需要花費數分鐘甚至數十分鐘的時間,寫兩個單測一下午過去了,時間都花在調試的啟動上了。
所以才會有那么多程序員覺得,單測好難寫啊,又耗時,還動不動就 fail,寫個 P。
所以回過頭來看,到底什么是單測?
在 JAVA 中,單元測試的對象是類中的某個方法,一個 Test 只需要關心這個方法的邏輯正確性,僅僅測試這個方法的邏輯,不應該也不需要關注外部的邏輯。
舉個例子,當你寫 service 的單測時候,你壓根就不應該測試 dao 或者外部服務返回的對不對,這是屬于它們的邏輯,跟我 service 沒有關系。
可能聽著感覺不強烈,我拿代碼舉個例:
假設我們要測試 trainingYes 這個方法,可以看到方法內部依賴 yesDao 和 OneOneZeroProvicer ,一個是數據庫,一個是 RPC 服務。
這時候我們的思維應該是:不管傳入的 id 在數據庫中對應的 yes 數據到底如何,我想讓 yesDao 返回 null 的時候它就要返回 null ,想讓它不為 null 就不為 null。
對 OneOneZeroProvicer 也是一樣,我想隨意操控讓它返回 false 或者 true。
因為數據庫和外部服務的邏輯跟我當前的這個 service 方法沒關系, 我只需要拿到我應該拿到的值來測試我的方法內部的所有邏輯分支即可 。
只有這樣,我們才能容易的測試到我們所寫的代碼邏輯。
你想想看,如果你要是測著 trainingYes 還得管著到底哪個 id 能拿到值啊,然后這個 yesDao#getYesById 內部邏輯有沒有狀態過濾啊,這個 id 對應的數據有被廢棄嗎,需要關心這個那個,這就非常累了。
再或者你想關心 OneOneZeroProvicer#call 怎樣才能返回 true,怎樣才能返回 false,這就更難了,因為這是別的團隊的服務,你連這個服務的代碼權限都沒,一個一個去問別人?
萬一沒這樣的數據呢,還得去造?
總而言之,單元測試僅需要關注自己方法內部的邏輯,不需要關注依賴方。
看到這,很多同學就搞不懂了,那該怎么搞?我的代碼就是依賴它們的服務了啊。
這就涉及到 mock 了。
mock 指的是偽造一個假的依賴服務,替換真正的服務,在上面的例子中,需要偽造 yesDao 和 OneOneZeroProvicer ,我們操控它得到我們想要的返回值,滿足我們自身對 trainingYes 的測試需求。
我拿 yesDao 舉例一下,如下所示,我 mock 了一個假的 dao:
然后 在單測時通過反射或者 set 注入的方式把 MockYesDao 注入到測試的 YesService 中 , 這樣一來,是不是就能控制邏輯了?
當我傳入的 id 是 1 的時候,百分百拿到一個不是 null 的 yes 對象,當傳入其他值的時候,肯定拿到的是 null,這樣就非常容易控制我要測試的邏輯。
當然,上面僅僅只是舉例說明 mock 的含義的具體作用方式,實際上真正單測的時候沒有人會手動寫 mock 服務,基本上用的都是 mock 框架。
比如我用的就是 mockito,這個我們后面再提。
至此,你應該對如何寫單測有點感覺了,我簡單總結下上面說的幾個小點:
- 單測不應該啟動整個項目(包括 Spring 容器),沒有這個必要,耗時長
- 單測不應該關心依賴的服務,包括 Dao、provider等其它服務,需要通過 mock 來解耦
- 一個測試方法只測當前要測試的一個類中的一個方法
其實就是分而治之的思想,本身在寫代碼的時候你已經為了降低復雜度和解耦,把代碼分成了一個一個模塊,一個個方法,而單元測試的目的,本就是驗證這些你拆分的方法自身邏輯的正確性。
為什么單測這么難寫
在對單測有點感覺之后,我們再來盤一盤為什么單測這么難寫。
核心原因在于, 我們本身寫的代碼不夠解耦 。
看到這有人不服了,什么?單測難寫還怪我本身寫的代碼不好,難寫是因為本身的業務邏輯復雜!
好吧,這里需要強調一下,邏輯簡單的類,其實沒必要寫單測,一般只是領導要求純粹的追求覆蓋率的時候,才會把這種簡單的類補上去。
舉個很簡單的例子:
studentService.getStudentById(Long id) ,我相信你都能腦補里面的邏輯,你要說你就想為這樣的方法寫單測,這當然可以,但是收益不大。
單測收益最高的就是針對那些復雜的場景,比方說在開發周期比較緊急的時候,核心的、容易出錯的邏輯才是更應該去重視的地方(要是開發周期空閑,你要補哪都行)
回到單測難寫的問題上,用專業術語來講,就是 你寫的代碼可測試性不高 ,導致難以編寫對應的單測類。
怎樣的代碼是可測試性不高呢?我舉個非常簡單的例子:
假設你要給 garbageMethod 寫個單測,是不是有點難?
里面用到了靜態方法,又 new 了個service。
這靜態方法我想讓返回值等于 111,我只能去研究里面的邏輯。有人可能想不就是一個方法的邏輯嗎,就看看唄。
那就看看:
可能你會說,這兩分鐘我就看明白了,但是這才一個,要是好多都需要看呢?
你為了測試當前的方法,且花了一堆時間去理解別的不需要測試的類的邏輯,這做法本身就不符合邏輯。
然后那個 noSevice 是 new 的,這如何控制它的返回值啊?我想 mock 這個類也替換不了啊!
所以,這樣的代碼就是可測試性低的代碼,不好 mock (當然,mock 框架支持靜態方法的 mock,不過new noSevice 不好弄,當然一般人都有不會這樣寫的,我只是為了舉例)
還有各種類之間有繼承關系的,這種測試難度都比較大。
就是上面的種種原因,導致我們的單測難以編寫。
所以如果我們在設計接口的時候,先編寫單測,我們寫出來的代碼其實可測試性就很高了,因為你完全曉得這樣的寫法會使得你單測很難進行下去,自然而然你寫的代碼就會往解耦的方向發展(比如上面的 noService 肯定會注入)。
我來列舉下具體哪幾種代碼寫法使得我們單測難以編寫:
- 靜態方法(不好mock替換注入,不過現在mock框架已支持)
- 內部直接 new ,強依賴,無法 mock 替換注入
- 繼承類,測試當前類的方法邏輯,還需要關心父類邏輯和mock父類的服務(所以我們常說組合優于繼承)
- 全局變量,這個應該好理解,好方法都公用,你改了值之后,會影響別的測試類,特別是并發執行測試類時,就傻了
- 時間等一些未決行為,代碼里面有 new Date,邏輯是近 15 天可行,然后超過 15 天就跑不通了(當然可以通過動態計算時間)
這里我要強調下,我不是說上面的這幾種代碼不能寫,這是不現實的,我只是列舉說明這幾種可能會使得你的單測不好寫, 當然第 2 點就是不能寫的 。
寫個單測例子
說了那么多,不如實戰一下,我就拿 trainingYes 來舉例說明,這里引入 mockito 測試框架。
可以看到,通過注解 mock 了需要 mock 的 dao 和 provider ,然后將其注入到我們要測試的 yesService 中。
接下來就是具體的邏輯,根據場景我一共寫了 4 個方法來測試:
里面的 when(xxxx).thenReturn(xxx) ,就是我們指定的 mock 邏輯,這就是指哪打哪,隨心所欲。
我們跑一下,你看就很快,59 ms,也不需要 Spring 框架。
就是通過這樣的 mock 手段,忽略了依賴的服務的邏輯,使得我們要它怎樣就怎樣,便于我們單測類的編寫。
至于具體的 mockito 的使用方式,這篇就不做展開了,網上看看應該簡單的。
然后上面提到的靜態方法的模擬,也簡單的,我截個網上的例子:
上面的邏輯就是模擬靜態方法 StaticUtils.name ,跟普通對象不同的是它用完之后需要 close 一下,所以用了 try-with-resource,當然也可以手動 close,原理也不做展開,有興趣的小伙伴可以自己去了解下。
看到這,想必你對單測應該已經挺有感覺了吧?
道阻且長
知道了單測如何寫和為什么難寫之后,其實我們的思路已經清晰了,但是往往現實還是殘酷的。
以前的老代碼,巨多,領導要求補,難!
一個 service 依賴十幾個服務,mock 都 mock 傻了,難!
項目太緊急了,從長遠來看,單測的收益會使得整體開發和后期維護的時間短,但是領導就是要求下周一上線,難!
我個人認為一些穩定的代碼,除非現在真的沒事做了,完全沒必要去補單測,完全可以在改動對應的點的時候再去補,然后新寫的方法都要求上單測,這是非常合理的。
如果寫業務的時候,同步寫單測,會促進你的思考,縷清思路,寫出的代碼因為可測試性高,自然而然就比較漂亮和解耦。
還有一點也很重要,其實我們寫單測的時候,不應該過多的關注內部的邏輯,舉個非常簡單的加法例子,我們單測只關心 add(1,1) 的結果是 2,我管你里面是的實現到底是位運算還是啥運算?
因為只有當我們的單測沒有過度的關心內部實現時,之后方法的具體實現變更(從普通的 +,變成了位運算),我們的單測才不需要進行對應的修改。
但實際上這種情況對我們業務不太適用。
舉個例子 YesService 之前依賴 yesDao ,現在這個 Dao 被剝離了,變成了另一個 RPC 服務,對應的我們之前所有的測試用例還是需要更改的,這是沒辦法的事情。
不過為什么我還要提一下這點呢?
比如你的測試方法里面有個 xxxService.save 邏輯,這個方法沒有返回值,后面的邏輯也不依賴它,那么就不要想著在單測是時候寫 verify(xxxService.save(..)); 來驗證這個方法是否被調用。
這樣驗證是否被調用其實意義不是很大,并且之后如果 xxxService 被移除了,單測就拋錯了,因為里面沒有調用 xxxService.save ,你還需要把這個單測給修復了。
這就是我所說的,寫單測的時候,不要過度關注方法內部實現(有些需要mock的沒辦法)。
最后
好了,說了這么多,相信你對單測應該有所了解了吧?
最重要的還是對單測有個正確的認識,然后掌握 mock 的技巧,寫新方法的時候,嘗試設計完接口后,先寫下單測,慢慢的你就會有感覺了,在寫單測時,你自然而然的會考慮到諸多邊界值的處理,你寫的代碼質量也會提高,漸漸地就會感受到單測的好處。
很多公司單測之所以推行不下去,就是因為沒有一個很好的宣講,或者說對單測的系統介紹。
我相信大家都是在一年中的某個月份,領導在會上突然來了一句話:我們接下來要寫單測!下個月覆蓋率要達到50%!
然后大家就吭哧吭哧開始寫了,寫么又是抄網上的一些例子,把整個項目一起,就進行集成測試了,然后寫著寫著,有人把數據庫改了,跑的好好地單測就掛了。
要么就是寫死數據,這個月單測是行的,下個月就掛了。
也沒有人告訴你這單元測試寫的不對,咱不是說寫在 test 包里面的代碼就叫單元測試。
一開始氣勢洶洶,后面虎頭蛇尾,這就是絕大公司執行單測的真實寫照。
領導很心痛,為什么就推不下去,大家都這么不積極,這么沒有主人翁精神嗎?
下屬頭痛加手痛,這tm啥玩意啊,是人寫的嗎?
就這樣,每年的某個時刻,你的領導都會突發開始抓單測,然后持續幾周或一個月,熱情逐漸消退,最后無人問津,領導也假裝不知道。
如此往復,年復一年。
我們每天過的日子,好像也是如此?