---
title: '單元測試框架 - JUnit'
disqus: kyleAlien
---
單元測試框架 - JUnit
===
## OverView of Content
[TOC]
## 單元測試框架 - 概述
如果沒有單元測試框架的幫助我們將很難單獨去測試每個單元(無法獨立運行每個測試),並驗證每個測試的結果
所以單元測試框架就是為了解決這個問題的產生而出現的工具
> 
### 框架特色
* 一般單元測試框架有以下幾個特色 (例子以 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)
}
}
```
> 
2. 將錯誤部分刪除,**再修正為正確**
```kotlin=
internal class MyNumberTest {
@Test
fun test_add() {
val res = MyNumber().add(1, 1)
// assertEquals(10, res)
assertEquals(2, res) // 期望值, 實際值
}
}
```
> 
:::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 選項
> 
```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%
> 
:::success
* 我們可以輸出 html 看得更明白,到底缺少了哪一行測試
> 
:::
## 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 的功能)
> 
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}")
}
```
> 
:::warning
* 每次測試時都會建立一個測試類,所以不能把紀錄(`beforeTimes`, `afterTimes`)放在類成員,必須放在靜態成員
以下是將紀錄成員換成類成員的結果,結果沒辦法正常紀錄
> 
:::
3. 其他類型註解
| JUnit 註解 | 功能 | 特性 |
| -------- | -------- | -------- |
| @Disabled | 忽略某測試函數 | - |
```kotlin=
@Test
@Disabled
fun test_isValidName_NiceName_True() {
assertTrue {
userCheck.isValidName("Hello123")
}
}
```
> 
:::info
* Kotlin 有自己重新定義註解的名稱,其功能是相同的概念
> 
:::
## 測試方案
如何判定是否是一個好的程式,也可以看看該 **程式的可測試程度,如果可測試程度高,這個程式就會是一個比較好的程式**
以下來看看如果要在程式中添加判斷,要如何添加、有哪些方法可以用 ?
### 返回值的判斷
* 一般來說測試會需要一個返回值才能做出測定,判斷程式的邏輯正確性,而我們可以透過以下方法來達到測試的最終值判斷
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`