--- title: '單元測試 - 概念' disqus: kyleAlien --- 單元測試 - 概念 === ## OverView of Content 在學習真正的單元測試之前最好先看過一遍單元測試基礎概念、定義,就如同你在學演算法時對於演算法 BIGO 的定義,OOP 中的六大設計原則 這些概念在腦袋中有印象後,你在撰寫程式時會不斷的去驗證你的想法(重複思考),最終你會越來越熟悉並熟捻於心,最終產生自己的想法 [TOC] ## 單元測試 單元測試是開發人員提昇程式品質、思考產品細節、深入理解類別 & 方法的最佳方法之一,所以當然值得我們好好學習 ### 單元測試的重要性 - 為何要學?保障了什麼? * 在學習前我們總得知道為甚麼要學 (這樣比較有動力),前面已經簡單說明了什麼是單元測試,那 **為甚麼要學單元測試** ? 1. 對於自己撰寫的程式更有自信 2. 減少程式出錯的機會 (並不是寫了單元測試就一定不會出錯 !) 3. 不用花一堆時間在 Debug ! (去做點更有意義的研究 或 耍廢 ?) :::warning * 有時候你會覺得單元測試很蠢,這麼簡單還要測 ? 但很多時候我們就是犯了一堆很蠢的錯誤 ! ::: * 重要性 * **回憶初衷**:因為我們不可能一直記得當初設計這個類得初衷,藉由測試可以快速回憶 * **純粹性、完備性** * **保證程式的可行性**:單元測可以保證幾個月前的程式可以正常運作 :::danger * **單元測試保障了函數的契約** 契約是該函數當初許下的承諾,這份「承諾契約」無法透過編譯器強制驗證使用者實現,所以這時可以透過「單元測試」來測定函數的契約 ::: :::success * 往往我們在撰寫一個功能時可以保證該功能的可行性,但是類與類之間多多少少存在著耦合關係,這會導致 **我們可能會為了完成這個功能去修改令一個類,但類 B 也相同依賴類 A,最終導致功能確實做完了,但類 B 壞掉了** > ![](https://i.imgur.com/fCRpOhX.png) ::: ### SUT - System Under Test * SUT 通常就是用來指被測試的目標(系統),所以如果在單元測試中看到 SUT 關鍵字,就可以理解是測試目標 :::info 有些人會用 CUT (Code Under Test, Code Under Test) 來命名被測試目標,跟 SUT 是同樣意思 ::: ### 何謂單元? * 所謂的單元並沒有一定的定義,它是相對的定義,當你站在不同角度來看代同一見事物會有不同的定義; :::warning * **相對的定義**: 對於醫生來說人類的內臟可能就可以稱為一個單元,對於生物學家來說一個細胞才是一個單元 ::: * 以程式來說,完整的提供一系列功能的稱為 **類** (多個相關功能),而在類中更小一點的單元稱之為 **函數**(一個函數代表了一個功能);因此 **對於程式來說,一個函數就是一個單元** > 不糾結於函數內細節,功能就代表了一個單元,**也就是我們在驗證一個具體結果** ### 單元測試 & 整合測試 * 看看下表可以很清楚的比較出兩種測試的差異 | | 依賴外部實體 | 速度 |特色 | | -------- | ------- | - | -------- | | 單元測試 | No | 快 | 一個改變情況 測試一個結果 | | 整合測試 | Yes | 不一定 | 多個改變情況(多個測試目標) 測試一個結果 (無法清楚定位任何一種狀況下的失敗) | > 整合測試有不可控的可能性 :::info * **依賴外部實體**? 也就是具體的實體資料,可能像是資料庫實時資料,當前時間... 等等,就算是依賴外部實體 (不能作假物件測試) ::: ### 怎樣的單元測試才算優秀? * 根據 **單元測試的藝術** 這本書,優秀的單元測試應該要有以下特質 (我合併了幾個點) 1. 可自動化 2. 容易被實現:你看完程式後應該可以滿快的寫出一個測試 3. 可重複執行:每次執行結果應該都要一致 4. 它的存在是有意義的:這個程式值得被重複執行,來驗證程式的可靠度 5. 任何人都可執行並得到相同的結果 6. 可模擬的物件 7. 獨立性:不依賴外部或是不穩定的來源 8. 失敗時清楚的知道失敗原因 ### 單元測試原則 * 在撰寫單元測試時我們一般會依照以下原則,如同我們在學習 OOP 時的六大原則一樣;這些原則並非一定要遵守,不過它可以用來提醒我們 | 原則 | 說明 | 補充 | | -------- | -------- | - | | Fast | 單元測試的執行盡量不要拖延時間,如果執行速度慢,極有可能之後就 (懶) 不去執行 | | Independent | 測試案例之間的相依性,每個測試案例不應該相互依賴,**一個測試一個責任** | 其中也不與外部相依 (網路、資料庫... 等等) | | Repeatable | 每個測式都是可被重複執行的,並且 **每次執行的結果都不須相同** | | | Self-Validating | 每個測式結果都能清楚知道失敗原因 ! | 保持錯誤的清晰度,不可模糊的驗證 | | Timely | 寫測試要如同寫程式,要即時快速不拖延 | 如同 TDD 開發,最少也要在 commit 之前寫完測試 | * 另外還有要注意以下事項 1. 單元測試中不應該存在判斷,否則就不能稱為單元測試 2. 測試應該要簡單、清晰,一出錯甚至不用 debug 就知道問題點,也盡量不要有 for 迴圈... 等等複雜邏輯 ### 單元測試的時機 * 通常單元是的時機就是在判斷的時候,像是 **if/else, switch, loop ... 等等都是單元測試可以測試的時機**;如下範例 ```kotlin= fun isValid(str: String) : Boolean { if(str.length > 10) { // 測試點 return false } if(str.startsWith("A")) { // 測試點 return false } return true // 測試點 } ``` * 其中除了判斷以外還有一些點需要依據業務邏輯去測試,像是測試 **設定值** (雖然他不是判斷,但是對於進入該函數後會對傳入的類進行修改,這就可以測試) ```kotlin= fun downloadFirmware(setting: Setting) { setting.apply { handleWay = AUTO_DOWNLOAD // 測試點 } } ``` ## 簡單的單元測試 一般我們在撰寫 單元測試 時,常常會使用到測試框架,這裡我們跳脫框架,看看 **如果沒有測試框架,那又該如何達成單元測試** ### 單元測試 Sample - Without 框架 ```kotlin= // 測試以下程式 class Login { fun isValid(str: String) : Boolean { if(str.length > 10) { return false } if(str.startsWith("A")) { return false } return true } } ``` * 接著不使用單元測試框架,直接在開發資料夾內來撰寫測試;以下如果有無法通過的測試就往外拋出 Exception 來代表錯誤 ```kotlin= class LoginTests { companion object { fun isValid_StrLenMoreThan10_False() { if (Login().isValid("Hello World Tests.")) { throw Exception("Str length more than 10 should return false") } } fun isValid_StrStartsWithA_False() { if (Login().isValid("AHello")) { throw Exception("When str start with `A` should return false") } } fun isValid_StrLenLessThan10_NotStartWithA_False() { if (Login().isValid("Hello")) { return } throw Exception("Str length len less than 10 and not start with `A`, it should return true.") } } } ``` 使用 main 開始測試 ```kotlin= fun main() { LoginTests.isValid_StrLenMoreThan10_False() LoginTests.isValid_StrStartsWithA_False() LoginTests.isValid_StrLenLessThan10_NotStartWithA_False() } ``` > 測試通過 * 從沒有框架的測試中我們可以發現幾個缺點 1. 每個測試之間無法隔離,測試失敗時可不方便尋找,錯誤不夠清晰! ```kotlin= fun main() { // 可能是它失敗? LoginTests.isValid_StrLenMoreThan10_False() // 可能是它失敗? LoginTests.isValid_StrStartsWithA_False() // 可能是它失敗? LoginTests.isValid_StrLenLessThan10_NotStartWithA_False() } ``` 2. 每次都必須全部執行,測試無法單獨執行! ```kotlin= fun main() { // 必須一起執行,無法分開 LoginTests.isValid_StrLenMoreThan10_False() LoginTests.isValid_StrStartsWithA_False() LoginTests.isValid_StrLenLessThan10_NotStartWithA_False() } ``` ## 其他 ### 回歸 regression * 回歸 regression:代表了以前正常運作但是現在無法正常運作的程式 :::info * 回歸 regression 測試就是在運作跟當前要測試的目標無關的測試;而單元測試就可以做到這點(保證了其他代碼的正確邏輯性) > 可以 **用來驗證其他單元沒有被這次修改的程式影響** ::: ### Legacy Code * **Legacy Code 意思是 ++遺留程式碼++**,也可以稱為歷史程式(不在使用的程式碼) > 但通常 Legacy Code 很難跟目前的程式做切割 :::info 也有人稱把 `只是能動的程式碼` 稱為 Legacy Code ::: ### 過度指定 over-specification * 我們在測試時指需要指定該測試需要的內容即可,若與該測試無關的指定(假設)我們應該要忽略,避免混淆測試的環境(也不方便之後閱讀) ```kotlin= interface ICost { fun getCost() : Int } // 該測試指需要 ICost 的假設,其他的則不須在意 fun camBooking(cost : ICost) : Boolean { val userCost = cost.getCost() return userCost <= 1000 } ``` ## Appendix & FAQ :::info ::: ###### tags: `Test`