---
# System prepended metadata

title: 好的單元測試
tags: [Test]

---

---
title: '好的單元測試'
disqus: kyleAlien
---

好的單元測試
===

## Overview of Content

[TOC]

## 好的單元測試 - 標準

* 如何定義一個好的單元測試，可以用以下幾個特色來檢視這個單元測試是否優秀（一個優秀的單元測試必須包含所有的特色）

    1. **可信賴（Trustworthiness）**：
        
        對於跑過完的單元測試結果有信心！你可以 **完全信賴這個單元測試（也就是說這個測試本身沒有 Bug）**

    2. **可維護性**：

        無法維護的測試可能包含，閱讀性差，測試之間關聯性重，或是由於業務邏輯導致測試頻繁的被修改（並且修改的代價重！）
        
        > 基本上沒有可維護性，漸漸的該測試程式就不會有人想要繼續維護、修復

    3. **可讀性**：
        
        可讀性是基本的基本，失去可讀性人們很容易會覺得這個程式難以維護，因為連看都很難看懂（排除對測試框架不熟悉的人），自然這些測試就會讓人覺得不可信任

## 創建可信賴的測試

要創建一個可信賴的測試程式（這裡指 Unit Test），有一些基礎原則、技術，如下：

* 刪除、修改測試時機點，修改後又該如何驗證

* 測試中不攜帶邏輯概念

* 一次測試指關注一個點

* 區分單元測試（綠色區域）、整合測試
    
    > 詳細請看 [**測試分類**](https://hackmd.io/9XopWYSbQC-7eTEDTb9Jsw?view#%E6%B8%AC%E8%A9%A6%E5%88%86%E9%A1%9E)
    
* 測試的審查（`Code review`、`Pair coding`）

### 修改測試時機

* 這首先要先了解你 ^1.^為何需要修改程式？ 再了 ^2.^解修改了測試後會造成啥問題？ 最後修改之後我們要 ^3.^如何去驗證？


* 我們從 **為何需要修改程式？ 開始看**，以下是我們可能去修改測試的原因
        
    1. **產品程式 Bug**：

        修改一個產品程式碼，導致原有的測試項目失敗，代表了你的測試找到了修改產品後的 Bug（好事 ～），這時 **要修改的不是測試，而是修改程式，讓程式符合測試**

        > 修改程式讓其符合測試，這個動作類似於 TDD 的技術

    2. **測試程式本身 Bug**：

        **測試本身存在 Bug 這是很難被發現的問題**（有可能你收到 crash report 時，去驗證測試沒問題並有包裹到，這時你才會發現 測試程式本身 Bug）

        :::info
        
        * 修改步驟如下
        
            1. 修復產品程式 Bug：修復成正確邏輯

            2. 第一關，撰寫失敗的測試：驗證失敗動作是否真的能失敗（如果你連失敗都做不到？那代表這個測試問題很大！必須重新 回到第一點 修改）

            3. 第二關，撰寫成功的測試：最後驗證正確的商業邏輯

        :::

    3. **API 界面修改，但測試目標沒變**：

        這個狀況很 **常發生在我們重構程式後**（有可能是 API 修改了參數、或是類的使用方式修改），再次運行單元測試導致單元測試一系列的失敗；

        這時可以在程式中設計一個隔離層（重複利用的部份），隔離測試程式本身、產品程式，當改動時你只需要跳整隔離層；如下圖
        
        > ![](https://i.imgur.com/f5Oi7kv.png)


    4. **商業邏輯修改導致 矛盾、無效 的測試**：

        對於同一段程式碼的測試，新寫的測試通過，但舊的程式失敗，這時就產生了商業邏輯上的矛盾！

        :::warning
        * 這的問題的出現，可以很好的反應出修改的以前的（修改前）商業邏輯跟後來的（修改後）商業邏輯有衝突、產生了矛盾！
            
            > 這時就可以需求向反應並再次確認
        :::
    
    5. **重複的測試**：有時候我們會不小心對同一段程式進程測試（有可能是不同人撰寫、或是測試的手法不同），這時就可以 **考慮** 刪除重複的測試

        > 這並非必須

        * 重複測試的優點：
            
            * 不同的測試手法，越有可能發現 Bug
            
            * 閱讀測試程可以看到同一個測試目標有不同的設計、語意

        * 重複測試的缺點：
            
            * 維護成本變高
            
            * 測試品質參差不齊，不易閱讀
            
            * 對於測試失敗時的定位會被混淆
            
            * 取名不易
            
        鑑於缺點稍多一些，所以還是建議刪除重複的測試（留下更優良、全面的單元測試，多餘但同樣測試目標的測試就可以刪除）


### 測試 - 不帶邏輯

* **有了邏輯，這個就不再是單元測試，而是整合測試**；單元測試簡單並且容易定位錯誤，而整合測試充滿了不確定因素、無法經確快速定位錯誤

* 單元測試中有以下語句都是包含了邏輯

    1. 迴圈：`while`、`for`、`for-in`
    
    2. 判斷：`switch`、`if-else` 


        :::warning
        * 當然除了以上語句，我們這再來看一個隱藏的特殊案例
        
            ```kotlin=
            // 待測試程式

            class NameWithRule {

                fun enName(firstName: String, lastName: String) : String {
                    return "$firstName $lastName"
                }

            }
            ```
            
            驗證單元測試如下，它的邏輯在那呢？請看註解
            
            ```kotlin=
            @Test
            fun enName_PairRule_True() {
                val stubFirstName = "Kyle"
                val stubLastName = "Pan"

                val sut = NameWithRule()

                assertEquals(
                    // 邏輯藏在這！手動拼接即是一個邏輯！
                    "$stubFirstName $stubLastName",
                    sut.enName(stubFirstName, stubLastName))
            }
            ```
            
            修改手動拼接的邏輯，我們應該寫死一個 100% 正確的值，來給簡化這個測試給人帶來的邏輯壓力
            
            ```kotlin=
            @Test
            fun enName_PairRule_True2() {
                val expectName = "Kyle Pan"

                val stubFirstName = "Kyle"
                val stubLastName = "Pan"

                val sut = NameWithRule()

                assertEquals(
                    expectName,
                    sut.enName(stubFirstName, stubLastName))
            }
            ```
        :::

### 一次測試一個驗證

* 再次強調，單元測試一次的測試只測試一個驗證，如果單元測試存在多種輸出的可能性，那可能導致我們無法快速定位錯誤！如下範例


    ```kotlin=
    // 待測試程式

    class NameWithRule {

        fun enName(firstName: String, lastName: String) : String {
            return "$firstName $lastName"
        }

        fun cnName(firstName: String, lastName: String) : String {
            return "$lastName $firstName"
        }

    }
    ```
    
    單元測試超過一個驗證會如何？出錯時你沒辦法快速定位錯誤是哪個驗證出錯，維護也不好維護
    
    ```kotlin=
    @Test
    fun enName_PairRule_True3() {
        val expectName = "Kyle Pan"
        val expectName2 = "Pan Kyle"

        val stubFirstName = "Kyle"
        val stubLastName = "Pan"

        val sut = NameWithRule()

        // 驗證 1
        assertEquals(
            expectName,
            sut.enName(stubFirstName, stubLastName))

        // 驗證 2
        assertEquals(
            expectName2,
            sut.cnName(stubFirstName, stubLastName))
    }
    ```
    
    :::warning
    * 可以用 Message 判斷呀
        
        當然可以，但是你有時間寫 Message (並且 Message 要寫的好)，不如把兩個測試分開，這樣反而可以快速定位錯誤，並方便維護

    :::

### 測試的審查 - Code coverage

* 首先 Code coverage 是檢視你的程式被測試覆蓋的程度，這相當重要（可能會有關於你對於程式的信心），但 **Code coverage 不等於優良的測試**

    :::danger
    甚至為了滿足 Code coverage 直接來個空（假）測試
    :::

* 測試的審查是相當重要的，其中我們有兩種基礎方案 ^1.^ `Code review` (後置檢查)、^2.^ `Pair coding` (寫程式時就討論、檢查)

    滿推薦 `Pair coding`，可以一起討論、相互提出意見



## 可維護的測試

我們在程式中會有許多的方法（包括私有、保護... 等等），在這裡我們要討論撰寫單元測試時的技術，如何透握這些技術實現可維護的測試

> 單元測試如果不去維護，隨著時間的推移，它會變得難以維護甚至是難以理解

### 私有、保護方法 - 方案

* 首先我們要先了解 私有、保護方法的意義，為何不公開所有方法？
    
    這有許多有力的原因像是，設計該模塊不希望使用者用到設計契約之外的方法、或是出於安全考量不讓使用者調用、亦或是為了整理程式而區分出不同函數 ... 等等

    :::info
    * **私有、保護方法** v.s **公開方法（契約）**

        私有、保護方法，非常有機會會因為重構而改變，而公開方法則不會如此輕易更動（否則會對使用者造成很大的修改困擾）
    :::
    

    * 所以我們應該要 **透過 公開方法，間接性的測試私有方法**！畢竟私有方法一定會有公開方法呼叫

        ```kotlin=
        class Login {
            
            // 私有方法
            private fun isAccountValid(account: String) : Boolean {
                return account.length < 10
            }

            // 私有方法
            private fun isPasswordValid(passwd: String) : Boolean {
                return passwd.startsWith("A")
            }

            // 公開方法（契約）
            // 單元測試透過 login 傳入不同參數來測試 private 方法
            fun login(account: String, passwd: String) {
                if (!isAccountValid(account)) {
                    throw Exception("Account invalid.")
                }

                if (!isPasswordValid(passwd)) {
                    throw Exception("Password invalid.")
                }
            }

        }
        ```

        :::success
        * 直接測試私有方法有用嘛 ?

            以商業邏輯來看，注重的是公開的方法，而如果公開方法都沒有全面的測試，反而選私有方法來測試，那這測試反而沒有任何意義
        :::


* 以下有幾個對於私有、保護方法的測試方案（取用哪個不一定，你必須考量）

    1. **讓私有、保護方法 轉為 公開方法**：這裡應該思考兩者對於使用者、開發者的意義，如果這個類的責任中可以添加公開方法，那就可以將私有轉為公開

        * 私有、保護方法
            
            開發者可以修改方法的實做方案，而不閉擔心使用者會在未知的地方使用
        
        * 公開方法
            
            對使用者來說它是作為一種行為、契約的存在

    2. **把私有、保護方法 抽取到令一個類中**：如果一個類中有許多私有方法，那可以考慮將這些私有方法全出抽出到令一個類中，我們就可以單獨測試這個類

        > 而原先類就轉為依賴這個獨立類別

        ```kotlin=
        // 將私有方法抽出，獨立一個類
        class IsValidInput {

            fun isAccountValid(account: String) : Boolean {
                return account.length < 10
            }

            fun isPasswordValid(passwd: String) : Boolean {
                return passwd.startsWith("A")
            }

        }

        class Login2 {

            // 依賴獨立出來的類
            private val utils = IsValidInput()

            fun login(account: String, passwd: String) {
                if (!utils.isAccountValid(account)) {
                    throw Exception("Account invalid.")
                }

                if (!utils.isPasswordValid(passwd)) {
                    throw Exception("Password invalid.")
                }
            }

        }
        ```
    
    3. **將方法改為靜態方法**：如果該方法不依賴任何原來的變數、狀態，那就可以將其抽為一個靜態方法
    
        > 當然這靜態方法也算對外公開的契約

    
    4. **將方法改為 internal 方法**：這要看語言是否支持（像 Java 不支持，但 Kotlin 支持），`internal` 的特性會將方法限制為該模組才可使用，這樣的特性會讓外部無法使用該方法、類
    
    
        :::info
        * 但要有共識，internal 方法是不可隨意修改的，它雖然不是對外的契約，但它是對一個模組的公開契約
        :::

:::warning
* 用反射測試私有、方法？

    呵呵？建議不要這樣做，這很不容易顯示出測試的錯誤點，並且也難以閱讀（你必須參照原來的程式的部份參數才能知道這是在測試啥）
:::



### 去除重複程式碼

* 在單元測試中，我們很常對於一個方法有多個測試狀況，這容易導致我們會分散不同方法作假物件，這在程式異動時，會讓我們付出較大的代價來重寫測試；

    以下有幾中方案去除重複的測試
    
    ```kotlin=
    // 待測程式
    class Login3 {

        var init = false

        fun login(account: String, passwd: String) {
            if (!init) {
                throw Exception("Init first.")
            }

            if (account.length < 10) {
                throw Exception("Account invalid.")
            }

            if (!passwd.startsWith("A")) {
                throw Exception("Password invalid.")
            }
        }

    }
    ```
    
    1. **輔助方法**：當多個測試有需要重複創建（使用）相同假物件時，我們可以考慮在單元測試中創建一個輔助方法，該輔助方法就是來執行重複動作的

        ```kotlin=
        class Login3Tests {

            @Test
            fun login_InvalidAccount_Throw() {
                assertFails {
                    getLoginInstanceWithInit().login("Baby", "")
                }
            }

            @Test
            fun login_InvalidPasswd_Throw() {
                assertFails {
                    getLoginInstanceWithInit().login("BabyBabyBaby", "Baby")
                }
            }

            // 輔助方法
            private fun getLoginInstanceWithInit() : Login3 {
                return Login3().apply {
                    init = true
                }
            }

        }
        ```

    2. **setup 方法**：如果多個測試方法，需要同一個物件，那可以考慮將其分配到 `@BeforeTest` 的方法中（就是測試開始之前一定會執行的程式碼）

        ```kotlin=
        class Login3Tests_2 {

            private lateinit var instance : Login3

            @BeforeTest
            fun setup() {
                instance = Login3().apply {
                    init = true
                }
            }

            @Test
            fun login_InvalidAccount_Throw() {
                assertFails {
                    instance.login("Baby", "")
                }
            }

            @Test
            fun login_InvalidPasswd_Throw() {
                assertFails {
                    instance.login("BabyBabyBaby", "Baby")
                }
            }

        }
        ```
        
        :::success
        * setup 方法建議使用在全部測試都使用一個物件，或是一個設定時，否則它會造成測試的可讀性降低；以下為使用 setup 的時機

            * 初始化工廠、全部測試的共用類
            
            
        :::
        
        :::warning
        * **setup 該避免的狀況**；以下這些狀況都會讓測試變得複雜、難以維護、降低可讀性

            * 過多設定
            * 準備假物件（Fake & Mock）
            * 初始化部份測試才會使用到的設定（可能該測試類有 50 個測試而 setup 方法就為了 5 個測試，而呼叫某個設定）
        :::

### 測試隔離 - 4 種狀況

* **每個測試應該活在自己的小宇宙中**，甚至不知道其他測試的存在（最少知識），這樣可以避免測試的相互干擾，當測試出錯時可以清晰的看出錯誤點在哪；

    以下有幾種測試隔離不清的案例
    
    1. **有順序限制的測試**
    
    2. **呼叫其他測試**
    
    3. **共享物件狀態**
    
    4. **外部共享狀態**

        > 如果出現以上這幾狀況，那可以將其稱為 **反模式**

1. **反模式 - 限制順序性**

    ```kotlin=
    // 以下有兩個待測試方法

    class OrderLimit {

        var isInit = false

        fun getHello() : String {
            if (!isInit) {
                throw Exception("Init first")
            }

            return "Hello"
        }

        fun getWorld() : String {
            if (!isInit) {
                throw Exception("Init first")
            }

            return "World"
        }

    }
    ```


    * 如果你的測試是一種流程測試的話，那極有可能那是個整合測試而不是單元測試；這種流程限制有以下幾個缺點

        * 測試笨拙、不易修改

        * 不論新增、修改、刪除… 都可能造成其他測試程式失敗

        * 由於息息相關，測試的取名也不易

        * 當測試出錯時難以追朔（錯誤不清晰，可能每次錯誤的點都不同）

            :::success
            * 你應該區分區單元測試、整合測試
            :::

    * 如果不同測試之間有規定要先執行哪個測試，另外一個測試才能通過，那這就是有限制順序的測試，**它的測試結果不穩定，無法作為單元測試**

        > 以下呈現順序性測試，這種測試相當不穩定

        ```kotlin=
        class OrderLimitTest {

            private lateinit var orderLimit : OrderLimit

            @Test
            fun getHello_getString() {
                orderLimit = OrderLimit()
                orderLimit.isInit = true

                assertEquals("Hello",
                    orderLimit.getHello())
            }

            @Test
            fun getWorld_getString() {
                // 該測試依賴 `getHello_getString` 測試

                assertEquals("Hello",
                    orderLimit.getWorld())
            }

        }
        ```

        :::danger
        * 大部分測試框架的測試是併發測試，並沒有順序性
        :::


2. **反模式 - 呼叫內部測試**

    ```kotlin=
    // 以下有兩個待測試方法

    class OrderLimit {

        var isInit = false

        fun getHello() : String {
            if (!isInit) {
                throw Exception("Init first")
            }

            return "Hello"
        }

    }
    ```

    * **測試的相互呼叫會讓測試單元之間產生顯性依賴的狀況，這也不算是單元測試**，因為你測試了多個狀況；它可能會導致以下問題

        * 維護難度上升，修改了共有的部份，可能會讓其他測試失敗

        * 必須思考測試的順序性，增加測試撰寫的難度（難度上升讓人想要維護的感覺就會下降）

        * 測試可能因為錯誤的原因失敗、成功（可能有令一個函數改變了其狀態）

        * 難以正確的取名

            :::info
            * 減少重複的測試程式？

                這是個好想法，但並不代表我們要在程式內共用測試，可以改用 setup 方案、工廠模式來生產或建構共同物件
            :::

        > 以下呈現呼叫內部測試，這種測試相當不穩定

        ```kotlin=
        class OrderLimitTest_2 {

            private lateinit var sut : OrderLimit

            @BeforeTest
            fun setup() {
                sut = OrderLimit()
            }

            @Test
            fun getHello_getString() {
                // 呼叫到內部令一個測試
                isInit_setTrue()

                assertEquals("Hello",
                    sut.getHello())
            }

            @Test
            fun isInit_setTrue() {
                sut.isInit = true

                assertTrue {
                    sut.isInit
                }
            }

        }
        ```

3. **反模式 - 共享物件 / 外部狀態**

    ```kotlin=
    // 以下有兩個待測試方法

    class OrderLimit {

        companion object {
            var isInit = false
        }

        fun getHello() : String {
            if (!isInit) {
                throw Exception("Init first")
            }

            return "Hello"
        }

        fun getWorld() : String {
            if (!isInit) {
                throw Exception("Init first")
            }

            return "World"
        }

    }
    ```

    * 這種情況最常發生在所有測試共享一個物件，或是測試同時操控物件的一個靜態（**static**）狀態！這危險性就是在 **共同** 的部份，它將原本沒有關係的單元測試進行了連結

        可能引發的問題如下

        * 維護測試變困難，要多思考是否會影響其他測試

        * 測試錯誤、成功其原因難以追尋（有可能在任何一個測試中被修改了我們也不知道）

        * 修改一個靜態 Field 會影響到多個測試

            :::success
            * 要解決這個問題，其實就是要記得在 `setup`、`tearDown` 去釋放共享的物件
            :::


        > 以下範例就是一個靜態物件的相互干擾案例


        ```kotlin=
        class OrderLimitTest_3 {

            private val sut : OrderLimit = OrderLimit().apply {
                OrderLimit.isInit = true
            }

            @Test
            fun getHello_getString() {
                assertEquals("Hello",
                    sut.getHello())
            }

            @Test
            fun getWorld_NotInit() {
                OrderLimit.isInit = false

                assertFails {
                    sut.getWorld()
                }
            }

        }
        ```

    :::info
    * 共享外部狀態 與 共享相同物件差異不大，差別只在它是共享外部的檔案、資料、時間... 等等
    :::

### 一個測試多個驗證

* 一個測試內建議最好只有一個驗證項目，否則測試失敗，你也沒辦法很快速的得知錯誤的原因，甚至需要使用 Debug 來抓出錯誤點（不利於可讀性）

* 如果一個方法有多個測試項目，建議可以使用以下方案
    
    1. **每個狀況單獨寫一個測試**：這是最普遍的作法

    2. **參數化測試**：這要看你的單元測試框架是否提供參數化測試的功能，如果不提供參數化測試，你可以自己使用創建方法（實做共用部份），並對那個方法傳入需要測試的參數、結果

### 物件比較

* 在做單元測試時我們偶爾會遇到一個窘境（至少我域到了...），我們要測試為傳物件的內容，而我們完整想測試它是否批配，這是否要分一堆測試項目呢？ 
    
    範例如下

    ```kotlin=
    data class DownloadFileInfo(val url: String,
                                val downloadable: Boolean,
                                val savedName: String)
    class MyDownloadManager {

        fun getDownloadInfo(url: String) : DownloadFileInfo {

            val downloadable = url.startsWith("https://")
            val savedName = url.substring(url.lastIndexOf("/") + 1)

            // 我想測試回傳，但是有三個元素，要寫三個測試？（甚至 3 個以上）
            return DownloadFileInfo(url, downloadable, savedName)
        }

    }
    ```
    
    在這裡我們有 2 種方法可以達到測試的目的（比對完整資料）又不寫過多感覺多餘的測試

    1. **建立 `expect` （預期物件），再比對預期物件**

        :::warning
        * 如果有需要只比較特定數據的話，要 override `equals()` 方法
            
            > 這時別忘記測試 `equals`
    
        :::
        
        ```kotlin=
        @Test
        fun getDownloadInfo_invalidUrl_Equals() {
            val stubFileName = "123.txt"
            val stubUrl = "file://555.666/${stubFileName}"

            val expect = DownloadFileInfo(stubUrl, false, stubFileName)

            val sut = MyDownloadManager()

            assertEquals(expect, sut.getDownloadInfo(stubUrl))
        }
        ```
    
    2. **程接上一個方法，這次複寫要比較物件的 `toString()` 方法，在測試時比較 `toString()` 方法**
    
        > 在測試失敗時，可以更清楚的看出是哪個部份讓測試錯誤

        * 複寫 `toString()` 方法

            ```kotlin=
            data class DownloadFileInfo(val url: String,
                                        val downloadable: Boolean,
                                        val savedName: String) {
                override fun toString(): String {
                    return "url: $url, downloadable: $downloadable, savedName: $savedName"
                }
            }
            ```
            
        * 測試時比較 toString 輸出

            ```kotlin=
            @Test
            fun getDownloadInfo_invalidUrl2_Equals() {
                val stubFileName = "123.txt"
                val stubUrl = "file://555.666/${stubFileName}"

                val expect = DownloadFileInfo(stubUrl, false, stubFileName)

                val sut = MyDownloadManager()

                assertEquals(expect.toString(), sut.getDownloadInfo(stubUrl).toString())
            }
            ```

:::warning
* 另外一個考量點是，是否需要全部比對呢？這樣是否會造成過度指定？
:::

### 避免過度指定

* 過度指定是指你對於測試的目的不清，導致你做了多餘不關這個測試的假物件；過度指定的幾個指標如下

    1. **測試 SUT 物件的內部切換狀態**：
        
        我們該驗證的目標不是內部的狀態，而是界面、方法對外的承諾，**也就是對外的契約才是我們測試的目標**

    2. **驗證 Stub 物件**：
        
        Stub 物件對於測試來說是一個不會改變的物件，我們不需要對一個 Stub 物件（虛設常式）進行驗證
        > 也不會多次驗證 Stub、SUT、Mock 物件
    
    3. **不必要的順序、精準的批配**：

        在測試的驗證步驟中，如果使用精準批配（`assertEquals`）字串輸出，那可能會導致之後的 Source code 小小測試也會出錯
        
        :::success
        * 是否精準批配這個具體看你的商業邏輯是否有這樣的需求，當然大多是希望可以使用 `assertContains` 這種方式比對
        
            > 而這個 **寬鬆** 的程度，也要看狀況，不可太過於寬鬆
        :::

## 可讀性

單元測試的可讀性分為下幾個點

* 命名單元測試
* 命名變數
* 單元測試錯誤時的額外訊息
* 操作 & 驗證分離

### 單元測試命名

* 一個好的單元測試（函數）命名可以幫助我們在還沒看測試程式時，就可以大致了解測試的大概樣貌；其中一個好的命名我們可以接收到以下訊息

    * **測試的方法**
    
    * **測試環境**（在怎樣的條件下測試該函數）
        
        > 可能可以預期到該測試會做啥假物件去協助測試目標
        
    * **預期的測試結果**（測試通過後，我們預期該有的結果）


* 依照以上三點，我們創建的測試名稱格式可以如下
    
    ```kotlin=
    @Test
    fun <測試方法>_<測試環境>_<預期結果>() {
        // todo
    }
    ```
    
    舉例：
    
    ```kotlin=
    @Test
    fun getDownloadInfo_invalidUrl_DownloadableSetFalse() {
        // todo
    }
    ```
    
    * 從這個範例中我們可以知道幾個訊息
    
        1. 測試函數：`getDownloadInfo`
        
        2. 測試環境：是個非法的 Url
        
        3. 預期的測試結果：預期應該返回該 Url 是不可下載的

:::info
* Kotlin 可以將函數透過 \` \` 符號定義，可以將函數描述成整個句子（可帶有空格）
        
    > 是否使用可以依照團隊討論決定
    
    ```kotlin=
    @Test
    fun `test getDownloadInfo fun, if set invalid url, it return cannot downloadable object`() {
        // todo
    }
    ```
:::


### 單元測試 - 內部變數命名

* 在查看單元測試內部時，好的變數命名可以讓你快速定位到該測試關注的要點，而不是全部都詳細看過之後才知道該測試的目標；我們可以分為以下幾點

    * **測試目標變數**：
        
        對於我們的測試目標，我們應該以 `SUT`(System under test)、`CUT`(Class under test) 來命名
        
        > 可以快速定位到測試目標

    * **模擬物件變數**：++這點很重要++

        模擬物件就比較麻煩，我們要根據它的作用而定
        
        1. 虛設常式：`Stub` 開頭命名
        
        2. 驗證物件：`Mock` 開頭命名
        
        3. 假物件：`Fake` 開頭命名（它的是使用者可操控的 虛設常式）


            :::danger
            一個好的單元測試內可以有多個 虛設常式物件，但只能有一個 驗證物件
            :::

    * **預期變數**：

        對於一個我們可預期的結果，我們應該將它寫清楚，給它一個變數名稱定義，而不是透過直接寫入的方式測試
        
        ```kotlin=
        @Test
        fun getMessage_AfterInit_getDefaultMsg() {
        
            // 清楚給予預期結果再驗證        
            val expectMsg = "Non Msg"

            val sut = cacheMsg().apply {
                init = true
            }

            val res = sut.getMessage()

            assertEquals(expectMsg, res)
        }
        ```
        
### 操作 & 驗證分離

* 建議把單元測試的操作（測試結果）、驗證（驗證行為）分開，如果混用則可能造成需要去閱讀更多的資訊才可以了解測試

    1. 沒有把操作、驗證分離
        
        ```kotlin=
        @Test
        fun getMessage_AfterInit_getDefaultMsg() {

            // 清楚給予預期結果再驗證        
            val expectMsg = "Non Msg"

            val sut = cacheMsg().apply {
                init = true
            }

            // 直接呼叫操作並同時驗證
            assertEquals(expectMsg, sut.getMessage())
        }
        ```
        
    2. 把操作、驗證分離

        ```kotlin=
        @Test
        fun getMessage_AfterInit_getDefaultMsg() {

            // 清楚給予預期結果再驗證        
            val expectMsg = "Non Msg"

            val sut = cacheMsg().apply {
                init = true
            }

            // 分離操作
            val res = sut.getMessage()

            // 分離驗證
            assertEquals(expectMsg, res)
        }
        ```

### 錯誤時的額外訊息

* 其實把上面幾點做的徹底，比你花時間寫驗證錯誤時的驗證訊息來的重要；但是如果碰到非寫驗證訊息不可的情況，請注意以下幾點

    * 不要重複框架已經給予的測試結果訊息

    * 不要重複已有的資訊

        > 可能你從測試方法名就可以知道的訊息，像這種訊息就不需要

    * 如果沒有有用的資訊... 不如寫清楚其他命名（寧可啥都別說，以免混要測試資訊）；所謂有用的資訊像是：
        
        1. 該測試應該要觸發哪些事件
        
        2. 該測試不應該觸發的事件
        
        3. 測試不通過的原因（記得不要與框架重複）






## Appendix & FAQ

:::info
:::

###### tags: `Test`
