--- title: '單元測試框架 - JUnit' disqus: kyleAlien --- 單元測試框架 - JUnit === ## OverView of Content [TOC] ## 單元測試框架 - 概述 如果沒有單元測試框架的幫助我們將很難單獨去測試每個單元(無法獨立運行每個測試),並驗證每個測試的結果 所以單元測試框架就是為了解決這個問題的產生而出現的工具 > ![](https://i.imgur.com/aN1iWQO.png) ### 框架特色 * 一般單元測試框架有以下幾個特色 (例子以 JUnit 為例) 1. **容易實現的結構化測試**: | 框架輔助 | e.g. | | -------- | -------- | | 協助測試,可繼承的基礎類別或界面 | TestCase (JUnit4 之前) | | 可標測試方法 | @Test, @Before, @After...等等 | | 驗證測試方法的類 | Assert 相關類 | 2. **單獨執行、全部執行測試**:提供測試執行器 | 框架輔助 | e.g. | | -------- | -------- | | 發驗程式中的須測試項目 | - | | 自動執行所以待測試項目 | - | | 執行期間顯示狀態 | - | | 可透過指令操作 | 可串接 CI 一起執行測試 | 3. **確認測試結果**: | 框架輔助 | e.g. | | -------- | -------- | | 可知道執行、未執行、失敗的數量 | 未執行像是 @Ignore | | 知道失敗原因,並指出是哪裡失敗 & Assert 訊息 | 並且可以看出錯誤堆疊 | | 測試覆蓋範圍 | Code coverage | :::success * 框架通常都取名為 <xxx\>Unit > 像是 `JUnit` (Java 語言), `CppUnit` (C++ 語言), `NUnit`(.NET 語言)... ::: ## JUnit 框架測試 以下使用 IntelliJ IDE;Java 的原生測試框架是 `JUnit`,要使用它需要在 `build.gradle.kts` 中添加依賴 ```groovy= dependencies { testImplementation(kotlin("test")) } tasks.test { useJUnitPlatform() } ``` ### 測試的 - 三個行為 & 從錯誤開始 * 單元測試一般會包含 **三個行為** 1. **準備(Arrange)物件**:建立模擬當前測試情況的物件狀態 2. **操作(Act)**:操作 SUT 物件,針對要驗證的函數進行操作 3. **驗證(Assert)**:驗證操作與你想像符合! * 要寫測試首先我們要先了解到,我們自身對於自己寫的判斷可能是的正確的,但寫出的程式可能出錯! ^1.^ 我知道 1+1 = 2 回傳 true; ^2.^ 但我寫成 1+1 = 10 也回傳 true,但我以為我寫出的程式是正確的(驗證錯誤),這就會出大問題,所以必須先證明錯誤 > 如果連錯誤都沒辦法證明,那成功更無法證明 :::success * 所以寫測試的第一點是,我們要先寫一個錯誤的測試 * 證明這段程式寫的錯誤邏輯如同自己所想;再將這段程式修正為正確的 * 如果連錯誤測試都通過了,那代表這段程式的邏輯一定是錯誤的 ! > **雙重保險邏輯** ::: ### 第一個測試 - 正向測試 * 現在我們來寫第一個測試,依照上面的觀念,先寫錯誤再修正為正確 :::info 在需要測試的函數上標記 `@Test` 註解 ::: 1. **先寫錯誤** ```kotlin= internal class MyNumberTest { @Test fun test_add() { val res = MyNumber().add(1, 1) assertEquals(10, res) } } ``` > ![](https://i.imgur.com/UXz32wl.png) 2. 將錯誤部分刪除,**再修正為正確** ```kotlin= internal class MyNumberTest { @Test fun test_add() { val res = MyNumber().add(1, 1) // assertEquals(10, res) assertEquals(2, res) // 期望值, 實際值 } } ``` > ![](https://i.imgur.com/qputWlL.png) :::info * 在判斷時我們不要把 `expected`、`actual` 寫相反,這樣的邏輯是錯誤的;不能寫 1+1 的期望值是 `res`,而實際值是 10 ::: :::success * **正向測試** 用正確的答案去驗證結果,這種行為稱為 正向測試 (上面案例就是) * **逆向測試** 如果使用錯誤的結果去驗證錯誤 (不是不行,只要多多考慮),這會多繞一層邏輯,並且要考慮到這樣的驗證是否縝密 ```kotlin= // 以下面的測試來講逆向測試就不夠縝密,畢竟要驗證的是一個準去的答案 @Test fun test_add_invert() { val res = MyNumber().add(1, 1) // 不縝密,如果 1+1 算出 3,也不等於 10 // 這就會導致測試過了,但程式運行還是錯誤 assertNotEquals(10, res) } ``` ::: ### Code coverage * Code coverage 是由 IDE 來幫助我們判斷當前程式的 **Unit Test 覆蓋率**,其中有分為三種 ^1.^ Class、^2.^ Method、^3.^ Line | Code coverage 分類 | 解釋 | 單位 | | -------- | -------- | -------- | | Class | 被測試的類 | % | | Method | 被測試的函數 | % | | Line | **每一個判斷** (包括 when、if/else ...) | % | ```kotlin= class Coverage { fun getBigger(num1 : Int, num2 : Int) : Int { return if (num1 > num2) { num1 } else { num2 } } fun getSmaller(num1 : Int, num2 : Int) : Int { return if (num1 > num2) { num2 } else { num1 } } } ``` 點擊 CoverageTest 選項 > ![](https://i.imgur.com/zYMNs7J.png) ```kotlin= internal class CoverageTest { @Test fun test_getBigger_1() { val res = Coverage().getBigger(30, 20) assertEquals(30, res) } @Test fun test_getBigger_2() { val res = Coverage().getBigger(10, 20) assertEquals(20, res) } @Test fun test_getSmaller_1() { val res = Coverage().getSmaller(30, 20) assertEquals(20, res) } } ``` 我們故意漏側一個判斷,可以看到 Line 的數值就不是 100% > ![](https://i.imgur.com/g9eAkB6.png) :::success * 我們可以輸出 html 看得更明白,到底缺少了哪一行測試 > ![](https://i.imgur.com/kfbbkb5.png) ::: ## JUnit 測試框架 Kotlin JUnit 框架依賴設置如下 ```kotlin= tasks.test { useJUnitPlatform() } ``` ### JUnit 測試註解 * JUnit 主要有幾個註解方便我們使用,以下我們使用 Junit 的註解來協助下面程式碼做測試 ```kotlin= class UserCheck { fun isValidName(name: String) : Boolean { if(name.length >= 10) { return false } if(name.startsWith("SB")) { return false } return true } } ``` 1. JUnit **在該類進行測試前** 使用註解:被註解的函數只會執行一次,可以用來創建公用色是目標 (SUT) | JUnit 註解 | 功能 | 特性 | | -------- | -------- | -------- | | @BeforeAll | 會在 **所有** **測試之前** 執行,並且只會執行一次 | **必須配合 @JvmStatic, `internal` 描述** | | @AfterAll | 會在 **所有** **測試之後** 執行,並且只會執行一次 | **必須配合 @JvmStatic, `internal` 描述** | ```kotlin= class UserCheckTests { companion object { private lateinit var userCheck: UserCheck @BeforeAll @JvmStatic internal fun initTestObj() { userCheck = UserCheck() println("Init test: $userCheck") } @AfterAll @JvmStatic internal fun finishTestObj() { println("Finish test: $userCheck") } } @Test fun test_isValidName_LongThan10_False() { assertFalse { userCheck.isValidName("1234567890_") } } @Test fun test_isValidName_StartWithSB_False() { assertFalse { userCheck.isValidName("SB123") } } @Test fun test_isValidName_NiceName_True() { assertTrue { userCheck.isValidName("Hello123") } } } ``` 可以看到共用物件只會初始化一次,不會被多次呼叫(這也就類似於 static 的功能) > ![](https://i.imgur.com/m5ifArF.png) 2. 進行 **每個測試** 前 JUnit 測試前後使用註解,我們可以使用這個註解來測試一下註解的物件是否只會被創建一次,並記錄被呼叫次數 | JUnit 註解 | 功能 | 特性 | | -------- | -------- | -------- | | @BeforeEach | 會在 **每個** **測試之前** 執行 | - | | @AfterEach | 會在 **所個** **測試之後** 執行 | - | ```kotlin= companion object { private var beforeTimes = 0 private var afterTimes = 0 } @BeforeEach fun readInstance() { println("Read instance: $userCheck, beforeTimes: ${++beforeTimes}") } @AfterEach fun finishOneTest() { println("Read instance: $userCheck, beforeTimes: ${++afterTimes}") } ``` > ![](https://i.imgur.com/OeIS9Bt.png) :::warning * 每次測試時都會建立一個測試類,所以不能把紀錄(`beforeTimes`, `afterTimes`)放在類成員,必須放在靜態成員 以下是將紀錄成員換成類成員的結果,結果沒辦法正常紀錄 > ![](https://i.imgur.com/oAhV82y.png) ::: 3. 其他類型註解 | JUnit 註解 | 功能 | 特性 | | -------- | -------- | -------- | | @Disabled | 忽略某測試函數 | - | ```kotlin= @Test @Disabled fun test_isValidName_NiceName_True() { assertTrue { userCheck.isValidName("Hello123") } } ``` > ![](https://i.imgur.com/w4gJXR7.png) :::info * Kotlin 有自己重新定義註解的名稱,其功能是相同的概念 > ![](https://i.imgur.com/INRoe2b.png) ::: ## 測試方案 如何判定是否是一個好的程式,也可以看看該 **程式的可測試程度,如果可測試程度高,這個程式就會是一個比較好的程式** 以下來看看如果要在程式中添加判斷,要如何添加、有哪些方法可以用 ? ### 返回值的判斷 * 一般來說測試會需要一個返回值才能做出測定,判斷程式的邏輯正確性,而我們可以透過以下方法來達到測試的最終值判斷 1. **返回值** ```kotlin= // 1. 返回值 fun add(num1 : Int, num2 : Int) : Int { return num1 + num2 } // -------------------------------------------- 以下為測試 @Test fun test_add() { val res = MyNumber2().add(1, 1) assertEquals(2, res) } ``` 2. **局部函數的判斷** ```kotlin= // 2. 局部函數的判斷 var reduceRes = -1 fun reduce(num1 : Int, num2 : Int) { reduceRes = num1 - num2 } // -------------------------------------------- 以下為測試 @Test fun test_reduce() { val obj = MyNumber2() obj.reduce(10, 1) assertEquals(9, obj.reduceRes) } ``` 3. **拋出異常(返回一個類)** ```kotlin= // 3. 拋出異常(返回一個類) fun multi(num1 : Int, num2 : Int) { throw ResException(num1 * num2) } // -------------------------------------------- 以下為測試 @Test fun test_multi() { try { MyNumber2().multi(5, 5) } catch (e : MyNumber2.ResException) { assertEquals(25, e.res) } } ``` 4. **CallBack 呼叫** ```kotlin= // 4. CallBack 呼叫 interface IResult { fun onResult(res: Int) } fun division(num1 : Int, num2 : Int, cb : IResult) { cb.onResult(num1 / num2) } // -------------------------------------------- 以下為測試 @Test fun test_division() { MyNumber2().division(50, 5, object : MyNumber2.IResult{ override fun onResult(res: Int) { assertEquals(10, res) } }) } ``` ### 基礎 - 依賴注入 * 上面我們知道了返回值的判斷,但通常在函數中並不會只有單單返回值的判斷,在一個函數內部可能還會有其他判斷,看看以下例子 ```kotlin= class PhoneState { fun isCalling() : Boolean { return true } fun getBattery() : Int { return 20 } } class UpdatePhone { enum class Result { OK, CALLING, BATTERY_LOW } // 被測函數 fun canUpdate() : Result { // 直接在函數內部建立 val state = PhoneState() if (state.isCalling()) { return Result.FAIL_CALLING } else if (state.getBattery() <= 30) { return Result.FAIL_BATTERY_LOW } return Result.OK } } ``` 可以看到 `PhoneState` 直接在函數內部建立,這會導致我們 **無法控制 `PhoneState` 狀態,從而無法判斷 UpdatePhone#`canUpdate`** * **DI (Dependency injection) 依賴注入** 就可以很好的解決這個問題,我們可以透過注入指定類來達成預設行為,以下有兩種方案 1. 將原有的 `PhoneState` 類設定為 `open`,讓其可以被繼承,這樣我們方便作假物件 ```kotlin= @Test fun test_canUpdate_NotCallingButBatteryLow() { val fakeState = object : PhoneState() { override fun isCalling(): Boolean { return false } override fun getBattery(): Int { return 20 } } val sut = UpdatePhone() assertEquals(UpdatePhone.Result.FAIL_BATTERY_LOW, sut.canUpdate(fakeState)) } ``` 2. 將原有的 `PhoneState` 重構為 Interface,由於 Interface 本來就是可被實作的,所以不會修改為 Open,**並且不會修改原有的類 !** ```kotlin= interface IPhoneState { fun isCalling() : Boolean fun getBattery() : Int } class UpdatePhone2 { enum class Result { OK, FAIL_CALLING, FAIL_BATTERY_LOW } fun canUpdate(state : IPhoneState) : Result { if (state.isCalling()) { return Result.FAIL_CALLING } else if (state.getBattery() <= 30) { return Result.FAIL_BATTERY_LOW } return Result.OK } } ``` :::success **這符合 OOP 的依賴倒置概念,依賴於抽象而不是實作**;如果依賴於實作會導致同時依賴於細節,最終導致程式會越來越難拆分,也不易替換 ! ::: ```kotlin= @Test fun test_canUpdate_NotCallingButBatteryLow_2() { val fakeIState = object : IPhoneState { override fun isCalling(): Boolean { return false } override fun getBattery(): Int { return 20 } } val sut = UpdatePhone2() assertEquals(UpdatePhone2.Result.FAIL_BATTERY_LOW, sut.canUpdate(fakeIState)) } ``` :::success * DI 主要分為以下幾種 (上面使用 Method injection),使用哪個要考慮到你對於類的職責設定 | DI 分類 | 特色 | | - | - | | Method injection | 可以在任何時候替換實作 | | Constructor injection | 在建構類時就設定好實作 (如果該類不希望暴露可替換接口,就可以使用 Constructor injection) | | Property injection | 直接對類成員進行設定 | | Ambient injection | 通常用在共享物件,也就是 Singlton 物件上,[**Ambient 參考**](https://www.huanlintalk.com/2011/11/dependency-injection-6.html) | ::: ## Appendix & FAQ :::info https://www.jianshu.com/p/899e80120071 ::: ###### tags: `Test`