阿里妹導讀:推廣單元測試,僅僅達到單測覆蓋率是遠遠不夠的,我們還要學習寫"易於測試"的程式碼,以及"好"的測試,這樣才能讓單測真正發揮作用。本文將分享作者關於單元測試的思考與實踐。
首先我就來回答一下標題提出的問題:單元測試除了是一種測試手段外,更是一種改善程式碼設計的工具,容易寫單測的程式碼往往也具有更加良好的設計。
因而是任何自動化測試工具都無法取代的。
當然,這裡也不是把自動化測試工具給一棍子打死,自動化測試工具也有自己的使用場景,比如測試遺留程式碼,做長鏈路測試等等。
這裡需要強調一下 "工具" 屬性,工具能放大人的智力或者體力,讓幹活的時候不會這麼累,比如你去種樹帶把鏟子,你肯定不會把鏟子當成負擔的,因為他是你種樹的工具,你寫 Java,肯定不會因為 IDEA 啟動時間長,就把它當成一種負擔,因為 IDEA 也是你寫 Java 的一個工具,很多人把寫單測當成一種負擔,往往就是沒有意識到"單測"是一種工具,單純把他當成一種測試。
一 品味篇
在品味篇,一起看看什麼樣的程式碼才是易於單測的。
Mock 工具的使用——毒藥還是解藥
你可能立刻就會產生和程式設計師小 A 類似的疑惑:"無論程式碼寫成什麼樣,透過 Mockito 和 PowerMock 肯定都是能寫出單測來?所以透過單測真的改善程式碼結構嗎?"。
實際上,大量使用 Mock 工具的單測相當於買櫝還珠,只具備測試的能力而無法幫助程式碼設計。
商店系統案例
以一段非常簡單的程式為例,假設這是一個商店系統,裡面有一個買麵包的方法,裡面會呼叫銀行提供的信用卡服務 creditCardService 來扣除傳入的信用卡的錢。這段程式如果使用 Mockito 的話,估計你很快就能寫出測試了,只需要把 creditCardService 給 Mock 掉,然後驗證它傳入的引數就可以了。
如果總是像上面這樣思考的話,單測對於你改善程式碼設計就沒什麼幫助了。我們在給程式碼寫單測的時候不應該上來就思考用什麼樣的工具來測試程式碼,而是應該思考如何重構程式碼,才能讓程式碼變得更加容易測試。
還是上面這段程式碼,我們換個角度,思考下如何重構程式碼,才能讓這段邏輯不需要 mock 就能測試?
返回一個執行計劃,而不是立即執行外部呼叫
上層拿到一個 Payment 實體後,可以選擇立即執行,或者稍後統一執行
其實非常簡單的一個辦法是,返回一個計劃,而不是立即就執行外部呼叫,比如這裡我們可以抽象出一個 Payment 實體,表示從銀行卡里劃了多少錢,外部拿到 Payment 實體後再決定是立即把錢劃掉,還是稍後把錢統一劃掉。此時這一段邏輯不需要 Mock 就可以測試了,只要校驗方法返回的 Payment 物件裡面的屬性是否正確即可。
到這裡,你可能又有疑問了,“費了這麼大事重構程式碼僅僅是為了好寫單測,值得嗎?”,如果你有這個疑惑的話,那你可能還是把單測僅僅當成測試了,我之所以要把程式碼重構的好寫單測,是因為好寫單測的程式碼還有其他諸多好處。
易於單測的程式碼僅僅是易於單測嗎?
更多的效能最佳化機會
就上面重構的程式碼為例吧,因為業務層返回的都是 Payment 物件,我可以這些 Payment 聚合起來,最後統一執行,比如下圖的這段程式碼,我就可以把 Payment 按照銀行卡分組統一扣錢,這樣就可以減少 rpc 呼叫的次數,以後如果有需要的話,甚至可以直接將 Payment 作為訊息發出去,到另一個系統執行,業務層根本無需關心 Payment 最後是怎麼執行,只需要在付款的時候生成一個 Payment 就可以了。
更加健壯的核心程式碼
更加健壯的系統
另一個更大的好處是,好寫單測的系統往往比不好寫單測的系統更加健壯,如果一個系統大部分程式碼都可以寫無 Mock 單測,那麼它看起來就像左圖一樣,外部呼叫只是薄薄的一層,可以隨意更換。
如果你的系統大部分程式碼都一定要 Mock 才能測試的話,或者根本無法測試的話,就像右圖一樣,說明你的業務根本就沒有自己的核心邏輯,而是和各種外部呼叫纏繞在一起。
另外需要說明的是,圖中紅色的部分才是單測真正能夠起作用的場景,因為它是比較穩定的業務邏輯,而且紅色部分的單測也比較好些,只需要傳幾個引數進去,然後校驗一下返回值就行了。灰色的外部呼叫部分理論上不寫單測也無所謂,因為外部呼叫是不穩定的,即使你跟對方約定好了出入引數,他依舊有可能返回不符合約定的引數,或者直接就發生了網路錯誤,這一部分是整合測試發揮的場景。為什麼在我們的系統裡,大家都覺得單測沒用,其實我也覺得單測對我們現在的系統沒什麼用,因為我們現在系統的主體程式碼就像右圖一樣,大部分都是灰色的外部呼叫,單測能夠發揮作用的領域少之又少,即使寫了覆蓋率 80% 的測試用例,又能測出來啥?
這裡要再補充一下,我上面所說的 “穩定” 的含義,我說紅色部分的“業務核心程式碼”穩定並不是說業務一成不變,業務肯定是一直在變的,而是說它的邏輯不會收到外部系統錯誤的影響,不像灰色部分,外部系統一抖動可能就會出問題,因為灰色部分不適合單測。
Mock 工具的定位
剛剛噴了這麼久 Mock 工具,那 Mock 工具真正的定位究竟是什麼呢?
- Mockito 是用來測試少量的不得不進行外部呼叫的程式碼。
- PowerMock 是用來測試設計得不好的遺留程式碼的。
在 PowerMock 的文件中已經給出了警告,濫用它帶來的壞處或許比好處更多,所以當我們寫單測的時候不應該上來就想著用這些 Mock 工具,而是應該想想如何重構程式碼才能避開這些工具的使用。
PowerMock 官方文件的警告:
Putting it(PowerMock) in the hands of junior developers may cause more harm than good.
另外,我們再聊聊單測自動化生成工具,我們剛好也有澄渢在做,無論是哪種單測生成工具,你會發現工具生成的單測到處都是 Mockito 和 PowerMock,顯然不符合單測的定位,但是這種工具也是有意義的,當系統裡到處都是不好寫單測的遺留程式碼時,用這個工具生成一下也能幫助我們覆蓋一小部分測試,對於我們系統目前的情況還是很有必要的。
再來一個重構的例子
寫有外部呼叫的靜態方法:
最後的結果:
為了加深大家印象,這裡再舉個一個例子。比如下面這個方法,我在靜態方法中呼叫先透過對 Business 的物件的各種處理,拿到了 rpc 呼叫的地址和版本號,然後使用這個地址和版本號載入一個初始化好的 hsf(阿里內部使用的 rpc 框架)泛化呼叫物件返回,這個方法的單測顯然十分難寫,因為 init 會發生網路呼叫,導致測試失敗。這個時候我們要反思一下單測不好寫的原因,是因為我們違背了一條編碼的基本原則——“不能在靜態方法中寫外部呼叫”,如果你就是想在靜態方法中進行外部呼叫,那應該怎麼辦呢?還是像之前的例子一樣,返回一個計劃,讓外部呼叫,首先保持程式碼無副作用的部分不動,這一部分本來就沒有外部呼叫,放在靜態方法裡執行也什麼事情,然後把外部呼叫部分封到一個 Operator 裡面(比如這裡就是 RpcLoader)返回給上一層,上一層自己選擇立即呼叫還是稍後呼叫。
這麼做除了好寫單測,還有什麼好處呢?最顯而易見的一點就是程式碼變得可複用了,更重要的一點是防腐,你會發現 hsf 影響範圍被侷限在 RpcLoader 裡面,以前哪怕它的 API 出現什麼變化,或者要換別的框架,都是件非常容易的事情。
為什麼單測能夠驗證程式碼結構的合理性
前面我提到的這些關於程式碼結構的概念聽起來是不是非常耳熟,在別的領域也經常聽到,比如物件導向中的“高內聚,低耦合”,DDD 中所提到的“核心域”,“防腐層”,函數語言程式設計所倡導的“隔離副作用”,你會發現,好的程式設計正規化倡導的東西都是類似的。
上面這三種評價程式碼的方式其實都是比較“主觀”的,什麼樣的程式碼才能叫“高內聚”,在每個人看來可能都不一樣。但是對於是否易於寫單測,大家的標準基本是一樣的,難寫單測的系統給誰都很難寫。而好寫單測的程式碼一般都滿足程式設計正規化所倡導的原則,所以寫單測的難易程度可以作為一個非常客觀的程式碼質量評價指標。
如果有人跟你說他這段程式碼設計得非常好,但是就是不好寫單元測試,千萬不要相信他。
另外再提一下設計模式,如果只是照著書抄抄程式碼,設計模式是非常簡單的,關鍵是要用對場景,一不小心就會只學到了“形”,而沒有學到“神”,“形神兼備”的設計模式往往會讓程式碼變得更加容易測試,如果用了設計模式發現系統變得更難測試了,那設計模式十有八九用得不對。
如果有個程式設計師跟你說我程式的效能達到了多少 QPS,你肯定會立馬拿起測試工具就去測,看到底能不能到達這個 QPS。但是如果有程式設計師畫了框框圖說他的程式碼分成了 A B C 模組,要怎麼驗證他的程式碼真的分成了這幾個模組呢?很簡單,你看看每一個模組能否脫離其他模組單獨測試就可以了,如果單獨測試非常困難,那就說明模組並沒有真的分開,而是或多或少耦合在了一起。
易於單測的等級
現在我們可以總結易於單測的幾等級了。和別的領域不太一樣,別的領域你高階的工具用得越多,可能越厲害,但是在單測這個領域,使用越多的高階工具,反而是更加糟糕的測試。
另外,對這些規則也不要死腦筋,這些只適合業務含義比較豐富的程式碼,如果你就是在寫一些封裝外部呼叫的程式碼,這部分程式碼我覺得不寫單測也是可行的。
- 第一級,易於單測:大部分程式碼不需要 Mock 就可以測試,少量的外部呼叫程式碼需要 Mockito。
- 第二級,能夠單測:超過一半的程式碼需要 Mock 才能測試,但是這些測試也不是特別難寫。
- 第三級,難以單測:大量 Mock,甚至大量使用了 PowerMock。
- 第四級,無法單測:模組被設計的及其複雜,連開發者自己都無法理解,更無法寫單測。
二 實踐篇
在上一篇學習了關於單測的正確觀念後,這一篇再來聊一聊關於單測的最佳實踐。
單元測試的執行速度重要嗎?
很多人會覺得單測反正也不是系統中的程式碼,執行的快慢無所謂,然後寫出很多其慢無比的單測,以至於系統全量跑一次單測要幾十分鐘。這樣的話就完全偏離了單測的定位,單測的目的就是為了方便快速迭代,改了兩行程式碼就可以在本地用 30 秒到幾分鐘的時間全量跑一次單測來確定影響範圍,而不是每次都要通讀系統原始碼才能知道改動的影響範圍,這樣新人很快就可以大膽改程式碼了,而不是先花幾個月通讀系統原始碼,或者先踩好幾個坑,才能上手幹活。那些全量跑單測要幾十分鐘的系統,他的開發者根本就不會在本地全量執行單測,每次都在 aone 上跑半天才知道單測不過,這樣的單測就形同虛設了。
違背這個原則的典型反例,就是在單測中啟動 Spring。
資料驅動測試(Data Driven Test)
不好的單元測試常常只用一組正常測試資料進行測試,實際上我們應該使用多組資料,包括正常和異常資料,輸入模組,看返回值是否符合預期。使用多組測試資料是否就意味著多寫很多程式碼呢?並不是,我們只要注意將測試用例的邏輯與資料分離就可以,測試程式碼依次讀取測試資料,校驗其是否符合預期。這樣的邏輯與資料分離的測試一般稱做 “資料驅動測試”,常見的單元測試框架都會提供這種支援。
"資料驅動測試" 的概念還是太抽象了,這裡我們看兩段程式碼,左圖未分離資料與用例,右圖則做了分離,能夠看出很明顯的不同,右圖是基於 Spock 單元測試框架來寫的,不熟悉的人看上去可能比較奇怪,可以把 where 標籤下的程式碼看成一張表格,每一行都是一組測試資料,Spock 框架會將其依次代入 testAdd 方法引數進行測試。
測試資料未與用例分離
測試資料與用例分離
大家所熟悉的 junit 框架也是可以做的,但是需要寫一個額外的內部類,加上@RunWith(Parameterized.class),寫一個 data 靜態方法,然後返回需要測試的資料組,然後 junit 就會依次將資料填入這個類的屬性中,執行這個類中的全部測試用例。
基於 junit 的資料驅動測試
基於 Spock 的資料驅動測試
如何測試私有方法
大家寫單測時常有的一個困惑就是私有方法怎麼測試?雖然理論上私有方法不需要寫單測,但是有些私有方法邏輯比較複雜,還是值得單獨寫測試的,目前公認比較好的實踐就是將修飾符從 private 改成 protected, 這也是很多開源專案給單測留口子的方法。如果你的專案剛好有引入 guava 的話,可以再給方法加上一個 @VisibleForTesting 的註解,表示僅僅是出於單元測試需要修改的修飾符。
一個典型的例子:
三 TDD 與 BDD
最後一篇來講一兩個大家可能經常聽說過的理念,TDD 和 BDD。個人覺得這兩個理念都比較極端,實際中很難應用,啟發意義大於其實用意義,所以放在最後,希望能帶來一些啟發。
TDD
TDD 強調讓寫程式碼的過程形成一個迴圈,第一步是為你要做的功能寫一個單元測試,跑一下發現沒有透過(畢竟你還沒有實現程式碼),即圖中的 TEST FAILS,俗稱“紅燈”,之後編寫能夠透過全部測試的“最小程式碼”,之所以強調“最小程式碼”,就是為了防止過度最佳化,現實中我們經常會因為程式碼過度最佳化,或者過度設計,導致很多遺留問題,在這個階段,只管用最快最髒的程式碼實現就好了,不用管太多設計問題。這個階段俗稱“綠燈”。
最重要的就是下面的“重構”(REFACTOR)階段了,前面的程式碼雖然可能很髒,但是至少是正確,也有足夠的測試來保障邏輯的正確,這個時候就可以大刀闊斧地重構程式碼了,保證程式碼繼續保持最優。
這啟發我們兩點:
- 單測必須能夠快速執行,因為單測是經常要在本地全量執行的,只有執行足夠快,才能在 TDD 的迴圈中快速迭代。
- 好的程式碼並不是一次性就設計出來的,而是持續重構出來,而單測是持續重構的前提。
BDD
我常常抱怨產品經理在提需求時沒有想清楚,比如下圖,如果讓產品經理也可以寫出可執行的測試用例的話,情況想必會好很多。BDD 就是這麼一個想法。
產品經理提需求
不知道大家有沒有在有的專案裡見過 .story 檔案,它本質上就是一種整合測試指令碼,只不過是用自然語言描述,它包含敘述,場景和步驟三部分,比如上圖就是一個書店管理應用的 .story 檔案,檔案中敘述(Narrative) 和 場景(Scenario) 只是幫助思考的,本身幷包含在測試用例的邏輯中,測試用例主要由 Given, When 和 Then 開頭的語句組成,含義如下:
story 檔案示例
story 檔案自己當然是無法執行的,需要框架提供支援,JBehave 就是這麼一種框架(右圖),能夠定義各種 Given,When,Then 語句的實現,下圖的程式碼本質上就是個基於 Selenide 的自動化介面點選測試,它支撐 story 檔案的執行。我們以這個 story 檔案為依據,就可以像 TDD 迴圈一樣,先測試不透過(紅燈),然後用最小的程式碼讓測試透過(綠燈),最後重構程式碼。只不過這個迴圈可能會耗時好好幾天,乃至幾個星期。而 TDD 一個迴圈可能只需要幾個小時,所以說 BDD 是整合測試版的 TDD。
JBehave 框架
敏捷
我們往往會覺得 TDD 和 BDD 會嚴重拖慢迭代速度,值得諷刺的是,TDD 和 BDD 恰恰是敏捷開發實踐的重要組成部分:
圖源維基百科 Agile software development
我們學習敏捷開發的時候,常常只學習到它的 “快”,而忽略了敏捷開發所提出的質量保證方法。敏捷開發所謂的“快”,是指在程式碼質量充分保證下的“快”,而不是做完功能就直接上線。
四 如何學習寫單測
學習單測的關鍵還是多實踐,多看看別人好的單測怎麼寫。比如可以給一些公認程式碼優秀的開源專案提交程式碼。
五 總結
- 單測能夠幫助我們驗證程式碼設計的合理性。
- 含有核心業務的程式碼應該首先思考如何讓主體業務邏輯可以寫無 Mock 單測。
- 用例資料儘量和測試邏輯分離。
參考資料
[1]Test-Driven Java Development
https://www.oreilly.com/library/view/test-driven-java-development/9781783987429/[2]Wiki Agile software development
https://en.wikipedia.org/wiki/Agile_software_development
[3]PowerMock
https://powermock.github.io/
[4]JBehave
https://jbehave.org/
[5]Spock
http://spockframework.org/
[6]JUnit
https://junit.org/junit4/
[7]Learning to Love TDD
https://medium.com/swlh/learning-to-love-tdd-f8eb60739a69