聊聊為什么需要單元測試?
沒有單元測試時的驗證
在學習編程和業務開發的工程中,我們有一段時間總是在討論:單元測試是否有用?而進行這種討論的主要原因是,我們似乎在不使用單元測試的時候,項目也可以跑得很好。小到畢業設計時的內容,大到一個十幾人大小的團隊。我們設計項目、分析需求,然后根據設計的結果進行代碼的編寫,然后進行接口或者業務執行上面的測試,讓我們知道所編寫的代碼已經可以完美的完成計劃內容后,會請測試同學幫我們進行代碼測試,以保證他們確實完成了計劃中的內容。最終,代碼上線,可喜可賀。
看起來沒什么不好的,直到最終的問題發生。
我們當然會在開發的時候進行項目功能的測試,常用手段諸如用main對指定的代碼塊驗證,或者使用postman對我們設計的接口進行測試驗證。或許中間還存在了一些數據庫的修改,比如模擬下單數據,或者模擬用戶注冊。
這些方法是一定程度上可以完成當期內的功能需求的,否則也不會有那么多的“單元測試真的有用嗎”的這種聲音。那么問題是什么呢?
問題是你無法永遠保證“當期的業務測試”就是能覆蓋你本期提供的功能點,以及即便是測試同學保存有以往所有測試用例的自動化測試內容,也無法真正的保證你的系統是完好的,因為業務功能和軟件功能中間是有隔閡的。
盡管是搞笑圖,但是精準的命中了我要說的內容:
針對用例設計的功能測試,無法保證你的“系統”正常。
測試驅動開發
我們一般在項目開發中進行的功能測試,是可以保證當期中業務流程。但是即便功能測試的過程包含了所有的過往功能,也只能保證業務流程是正確的,不能保證你的設計在未來的擴展中是正確的(舉例就是業務可能只需要正常流程,但是沒有異常流程的需求)。
所以,如果要代碼能實現所有計劃的功能,就要由開發者來編寫其對應的測試模塊。因為是開發人員,所以知道自己的所有邏輯組合是什么,而根據這種需求編寫的測試代碼則可以長久地對你的系統進行測試。而這就是就是:
TDD(測試驅動開發)
測試驅動開發中最主要的準則是:
在編寫業務代碼之前先編寫單元測試。
這條準則的目的在于:不要編寫沒有單元測試的代碼。實際上我們在編寫功能業務的時候,一般都會假設一個入口,然后再經過一段邏輯處理之后,最終返回一個結果。而先編寫單元測試的目的,就是先將你的這個假設直接落地到代碼中,那么在后續的編程過程中你就可以忽略這部分的假設,專注于邏輯編寫,甚至即使你最后忘記了之前的假設也不要緊,因為你已經將他寫道代碼中了。
而這一準則的進一步拆解,可以將其細化為三個準則:
- 在編寫不能通過的單元測試前,不可以編寫生產代碼。
- 只可編寫剛好不能通過的單元測試,不能編譯也算。
- 只可編寫剛好足以通過當前失敗測試的生產代碼。
根據細分的這三個準則,我們可以將我們編寫一個邏輯的的步驟變成:寫一個剛好失敗的單元測試,然后用剛好滿足邏輯的生產代碼滿足它。這樣一個小循環可能只是在一兩分鐘內就進行一次。而在IDEA等現代IDE的幫助下,可以在test包下的同包路徑創建對應的測試方法,大大加快了單元測試的編寫時間。而通過剛好的異常,讓每一次的業務邏輯得到了控制,通過剛好滿足則讓每一次生產代碼的編寫不會過度發散。因為如果你對生產代碼過度設計,那么你也需要對應的單元測試代碼來保證你設計的的得當性。
如果按這種循環進行編寫,則我們在編寫業務代碼的同時只需要多十幾秒就可以完成單元測試的編寫。而單元測試可以完整地覆蓋業務單元元。但是隨著業務代碼的增加,測試代碼的數量也將急劇增加,其對應的管理也是一種挑戰。
系統進化的保證
回到最開始的例子,我們說在一些團隊中,我們總是覺得單元測試是低效的,會影響業務的上線速度。我們也說這種方法看起來沒什么不好的,直到最終的問題發生。而這最終的問題就是:重構。
這里說的重構并不一定是大范圍的整體系統重構。我們在之前的文章《如何阻止軟件退化》中提到:要保持軟件設計質量不退化,必須要在每次需求變更的時候,根據變更點調整原有程序的設計結構。
而當我們相對原有的程序結構進行調整的時候,我們無法確保對代碼的改動能如預期的工作,也無法保證系統中的某個修改點是否會影響到系統的其他部分。舉個例子:當你會支付的路由進行修改的時候,如果出現意外則會導致其他支付方式的失敗,但是如果確保功能正常則你需要將所有的支付邏輯都進行一遍功能測試,而仍然可能存在功能點的遺漏(這個是親身經歷)。因為害怕新增加的功能會帶來更多的bug導致加班,最終的結論就是我們可能會抗拒對功能結構的調整,而變成所謂的“屎上雕花”。所以從這個角度上來說,如果沒有單元測試,則軟件將不可避免地直線的退化。
而相反地來說,如果我們的系統包含單元測試。我們才不用擔心對于代碼的修改,每一次調整都能通過那些“剛好”的單元測試,那么不論你如何進行設計模式的重構,都不用擔心引入新的不可預知的缺陷。
所以當有了單元測試,才能讓我們的系統有了進一步的維護性、擴展性,也才有了系統進化的可能。
應該被重視的單元測試
我們需要單元測試來保證系統功能的擴展性、可維護性。但是這并不意味著,只要有單元測試就可以。事實上我們應當如同生產代碼一樣的重視單元測試。原因很簡單:
單元測試代碼同樣會隨著功能調整而變得腐化。
而如果當它腐化的難以維護的時候,誰都不會愿意去修改它。最終的結果就是我們不用單元測試了,然后失去了代碼的擴展性。所以測試代碼必須要隨業務代碼的修改而同步修改,并不能因為單元測試只運行在測試環境而輕視單元測試的編寫,我們同樣需要讓單元測試的代碼也足夠整潔,讓其便于維護。
測試的邏輯
單元測試的時候重要的是體現出當前進行的測試內容,而讓別人理解測試內容的重中之重是測試“可讀性”。如果單元測試的代碼中充滿的一長串的業務邏輯或者斷言內容,那么讀起來就會十分的費勁。為了避免開發人員淹沒在代碼的細節中,有一種較為公認的單元測試的構造方法:構造-操作-檢驗(BUILD-OPERATE-CHECK),并使用give-when-then的命名方式來進行命名。
舉一個例子(這里直接用的CLEAN CODE的例子了):
givenPages(xxx);
whenRequestIsIssued(xxx);
thenResponseShouldBeXML();
其中,第一部分將構造測試數據的內容分裝到given開頭的方法中;第二部分將操作測試數據的內容封裝到when開頭的方法中;第三部分將檢查操作是否得到預期的結果封裝到then開頭的方法中。
這樣就屏蔽了絕大部分的代碼細節,并用方法的名稱直接描述了測試的前置條件、處理過程、判斷結果。同時當我們涉及到一些復雜流程的判斷的時候,我們是可以單獨為單元測試來編寫一部分額外的方法來支撐單元測試。這樣可以讓人變這樣可以讓人快速地理解單元測試的邏輯。
可以放松的部分
盡管說我們需要讓單元測試保持代碼保持整潔,并需要向生產代碼一樣地重視它。但并不意味著我們的測試代碼和生產代碼的準則是完全一樣的。因為單元測試的準則是具有可讀性的代碼并能精準地描述關注的測試功能邊界。
所以有一些內容是不需要和生產代碼保持一致的。其中最明顯的就是性能要求。
我們在線上代碼中需要對系統性能進行各種優化,但是單元測試的代碼是跑在測試環境中并且單個邏輯每次只執行一遍,對單元測試來說,0.1ms的邏輯和1ms的邏輯差距可能并不明顯。這樣的情況下我們可能會選用一下表達能力更強的方法來進行項目的編寫比如使用"+"號對字符串進行拼接,我們一般都會用StringBuilder,但是不得不說直接使用“+”拼接的實現可讀性更高一點。除此之外還有一些異步的功能可以使用串行化來校驗,以便校驗每一步的結果。
單一概念
為了保證每一個單元測試中邏輯的可讀性,所以我們希望每一個單元測試只對一個概念進行測試,這樣就可以用一組give-when-then的方法來對這個測試概念進行描述。當我們發現單元測試存在多個概念的時候就會將他們拆開分別進行測試。這樣就避免了多個概念聚合在一個單元測試方法中的時候,會猶豫復合概念導致掩蓋了一些遺漏的測試點在其中。同時也保證了單元測試的可讀性。
其他原則
除此之外,單元測試還要保證:
- 快速性:單元測試可快速執行,支持頻繁測試。
- 獨立性:單元測試不互相依賴,隨時以任意順序執行。
- 可重復性: 單元測試可以反復執行且結果統一,否則永遠會有功能失敗的借口。
- 可檢驗:單元測試要明確地通過布爾值來表示檢測結果,而非通過其他諸如日志的輔助手段。
- 及時性:要在開始編寫業務代碼前編寫,讓業務代碼去覆蓋測試。
最后
本文討論了單元測試的必要性以及單元測試中的一些重點注意實現。有人會覺得單元測試影響開發效率,但是站在項目管理者的角度上來說,有了單元測試項目才有了持續進步的可能。