---
# System prepended metadata

title: 設計測試層級 & 組織
tags: [Test]

---

---
title: '設計測試層級 & 組織'
disqus: kyleAlien
---

設計測試層級 & 組織
===

## OverView of Content

[TOC]

## 自動化測試

* 如果想讓團隊更加敏捷，隨時準備好處理需求異動，那我們就必須達成以下事項

    * 每次提交都確保跑過所的的測試項目
    
    * 確保程式碼能很好的被整合，並且能與依賴的專案相容
    
    * 建立交付套件（deliverable package），也就是一鍵佈署

        :::info
        以上這些事項可以透過自動化測試來達成
        :::
        
* 整體流程概念圖如下，其中包括本地腳本、伺服器、觸發器

    > ![](https://i.imgur.com/us6qE98.png)

### 手動整合

* 與自動化相反，在尚未自動化的程式中我們必須要手動作以下動作來進行程式整合
    
    1. 從版本控制中，取得最新的程式 （git pull）
    
    2. 手動在本地（自己的電腦）編譯程式

        * 如果有衝突就必須修復（git merge）

        * 修復完成後加入自己的程式碼再次編譯
    
    3. 本地運行測試項目，確保所有項目都通過測試


        :::success
        * 建構流程？
        
            建構流程是一種 **邏輯概念**，其中包含了建構腳本、建制伺服器、建制觸發器... 等等概念
        :::


### 建制腳本

* 設置腳本有一個原則：可以有多個腳本，但 **一個腳本負責一個功能**

* 腳本是建立在本地專案上，並且需要一起推送到遠端；**腳本是建立給遠端 CI 伺服器觸發的**，腳本建構包含如下
    
    1. CI（Continuous Integration）建構腳本
        
        > 最基礎腳本，用來觸發 **單元測試**；常會被一天觸發多次
    
    2. 每日建制腳本
    
        > 這個階段會觸發 **整合測試**，並且通常會建立在 CI 建構腳本 之後；一天最少會被觸發一次

    3. 佈署建制腳本
        
        > 佈署建制腳本本身就是一個交付機制（delivery mechanism），通常由 CI 伺服器觸發


* 建立腳本工具有如下

    | 工具名 | 補充 |
    | - | - |
    | Gradle | Android 推薦建構專案的工具 |
    | NAnt | .NET 平台的建制腳本工具 |
    | FinalBuilder | 一個通用的建制腳本工具 |
    | Rake  | 是 Ruby 平台的建制腳本工具。 |
    | Visual Build Pro | - |

    :::info
    腳本建議還是建立在本地比較好，避免遠端 CI 伺服器負擔過多責任導致不好維護
    :::

* 持續整合伺服器有如下

    | 工具名 | 補充 |
    | - | - |
    | CruiseControl.NET | - |
    | Jenkins | - |
    | Travis CI | - |
    | TeamCity | - |
    | Hudson | - |
    | Visual Studio Team Doundation Service | - |
    | TohoughtWorks Go | - |
    | CircleCI | github 專案常用 |
    | Bamboo | - |



### 觸發設置 & CI

* 大部分的持續整合 (CI) 伺服器都會包含以下功能
    
    * 按照指定事件去觸發專案中的建構腳本
        
        > 觸發器可以設定為原碼更動時、時間週期又或是依賴於另外一個建構任務上
        
        :::info
        建議腳本中指使用一個可執行命令，並且把這個命令放置在本地專案的腳本中
        :::
    
    * 提供建制腳本的上下文（Context）及資料
        
        > e.g: 版本、原始碼快照、來自於其他建構的產物（artifact）、腳本參數

    * 提供建構歷史紀錄、分析
    
    * 顯示目前所有啟用、尚未啟用的建構狀態


## 測試分類

可以依照測試運行的時間、測試方向（Unit test 或是 整合測試）來區分，不同測試放置不同的資料夾

:::info
* 謂何要區分開 Unit test & 整合測試？
    
    **Unit test**：測試快速，錯誤時可以快速發現並處理

    **整合測試**：測試耗時，一個測試可能包含多個錯誤的可能性，更趨近於上層應用的測試
    
    > 這種測試不容易維護，出錯時也不容易修改，常常會導致後期不便維護
:::

### 綠色安全區 - 測試放置位置

* 綠色區域

    1. 綠色安全區只要 **存放 Unit test**，每當從倉庫拉下最新的原始碼版本時，或是 build 時就可以去處發綠色安全區

        > 可以寫一個腳本讓持續整合任務的 CI 去運行綠色安全區（常跑）

    2. 綠色區域之外就放置整合測試

        > 整合測試的任務會比較耗時，所以可以改成每日建置的任務（不用很常跑）

* 測試也加入版控

    1. **測試放置位置**：**重點是可以快速找到該類的測試放置位置**，建議將測試類別的位置與被測試程式放到相對位置
         
        > ![](https://i.imgur.com/hUKzaKW.png)
    
    2. **測試加入版控**：在決定測試位置之後，我們 **要將測試、與源碼放在一起，因為每個版本會有的測試並不相同**

### 測試分類

* 我們在測試一個類 (class) 時，測試函數有以下兩種方式可以存放，幫助我們快速找到相關測試檔案

    1. **每個測試類別創建一個測試檔案**：
        
        例如：`HotPot` 類、測試類 `HotPotTests`

        ```kotlin=
        // 源碼
        class HotPot {
            fun hello() {
                // No matter
            }
        }

        // -----------------------------------------------
        // 測試類
        class HotPotTests {

            @Test
            fun hello_XXX() {
            }
        }
        ```
    2. **每個功能對應一個測試類別**：如果一個方法中含有多個狀況要測試，那可以考慮針對一個功能創建一個測試類

        例如：`HotPot` 類、測試類 `HotPotWorldTests
        
        ```kotlin=
        // 源碼
        class HotPot {
            
            fun hello() {
                // No matter
            }
            
            fun world(...) {
                // 多狀況要測試
            }
        }

        // -----------------------------------------------
        // 測試類分兩項
        
        class HotPotTests {

            @Test
            fun hello_XXX() {
            }
        }
        
        class HotPotWorldTests {

            @Test
            fun world_AAA() {
            }
            
            @Test
            fun world_BBB() {
            }
            
            @Test
            fun world_CCC() {
            }
            
            @Test
            fun world_DDD() {
            }
        }
        ```
        :::info
        檔然你也可以分析是否將這個方法拆的更細，分多函數
        :::

## 注入橫切面

一般來說我們的方法會使用界面的方式，透過 **建構函數、方法注入來達到作假物件的能力**，但這種方式並不一定符合需求，**有時反而會增加層使用複雜度**

```kotlin=
// 拆成接口注入就會增加複雜度

object TimeLogger {

    fun showMsg(str: String) : String {
        // Instant.now 就是橫切面
        return "${Instant.now().epochSecond}:$str"
    }

}
```

### 修改橫切面

* 由於 Instant#now 是依賴外部現實的時間，所以無法確定確切的值，這個狀況就不符合 Unit test 的測試環境；這時就可以 **使用中間類（也就是修改橫切面）**

    ```kotlin=
    object TimeLogger {

        fun showMsg(str: String) : String {
            // 替換 SystemTime 為 Instant 類
            return "${SystemTime.now().epochSecond}:$str"
        }

    }

    // 測試
    object SystemTime {

        var custom : Instant? = null

        fun now() : Instant {
            if (custom != null) {
                return custom!!
            }
            return Instant.now()
        }

    }
    ```
    
    透過中間類 `SystemTime` 來達成替換 Instant 的不確定性
    
    ```kotlin=
    class TimeLoggerTests {

        @Test
        fun showMsg_setMsg() {
            // 透過替換 SystemTime 靜態對象來達到，修改切面
            SystemTime.custom = Instant.ofEpochMilli(0)

            val subMSg = "No matter"
            val expect = "0:$subMSg"

            val sut = TimeLogger.showMsg(subMSg)

            assertEquals(expect, sut)
        }

        @AfterEach
        fun afterTests() {
            // 在所有的測試之後，必須將重新修正為 null (重新回歸到外部依賴)
            SystemTime.custom = null
        }


    }
    ```
    > ![](https://i.imgur.com/9GiFj61.png)


## 測試層級

這裡我們再探討在測試類別中使用繼承來簡化重複的測試步驟

:::warning
* 請在測試類別中 **限制繼承層數不要大於 1 層**，避免測試本身有不易閱讀、增加複雜度
:::


### 繼承 - 基礎類別

* 延續 **橫切面注入** 的例子... 如果有多個類會使用到 `TimeLogger` 這個工具類（如下），那我們在測試時，就會不方便使用一般的方式注入

    ```kotlin=
    // 源碼 --- 待測試
    class LoginAction {

        fun isLogin() : Boolean {
            TimeLogger.showMsg("check login status.")
            // 省略邏輯，重點不在這
            return false
        }

    }

    // ----------------------------------------------------
    class LobbyAction {

        fun isInLobby() : Boolean {
            TimeLogger.showMsg("check lobby status.")
            // 省略邏輯，重點不在這
            return true
        }

    }
    ```
    測試程式如下（尚未使用繼承），可以發現重複的點 ^1.^ 重複設定假物件、^2.^ 重複釋放物件（@After）
    
    ```kotlin=
    class LoginActionTest {

        @Test
        fun isLogin_False() {
            // 重複設定
            SystemTime.custom = Instant.ofEpochMilli(0)

            val sut = LoginAction()
            assertFalse {
                sut.isLogin()
            }
        }

        @AfterEach
        fun afterEach() {
            // 重複釋放
            SystemTime.custom = null
        }

    }

    // ----------------------------------------------------
    class LobbyActionTest {

        @Test
        fun isInLobby_True() {
            // 重複設定
            SystemTime.custom = Instant.ofEpochMilli(0)

            val sut = LobbyAction()
            assertTrue {
                sut.isInLobby()
            }
        }

        @AfterEach
        fun afterEach() {
            // 重複釋放
            SystemTime.custom = null
        }

    }
    ```

* 將上述重複的地方透過繼承來取代


    ```kotlin=
    open class BaseTimeLoggerClass {

        fun fakeTimeLogger() {
            SystemTime.custom = Instant.ofEpochMilli(0)
        }

        @AfterEach
        fun afterEach() {
            SystemTime.custom = null
        }
    }
    ```

    :::info
    * 為何不使用 `@BeforeEach` 註解？

        不是不行，但是如果在 `@BeforeEach` 時初始化過多假物件反而會讓繼承這個類的測試使用者帶來更大的負擔（他必須了解基礎測試類的功能）
    :::
    
    修改 `LobbyActionTest`、`LoginActionTest` 類讓其繼承 `BaseTimeLoggerClass` 類，最後會發現真正的測試類更加乾淨、方便測試
    
    ```kotlin=
    class LoginActionTest : BaseTimeLoggerClass() {

        @Test
        fun isLogin_False() {
            fakeTimeLogger()

            val sut = LoginAction()
            assertFalse {
                sut.isLogin()
            }
        }

    }

    class LobbyActionTest : BaseTimeLoggerClass() {

        @Test
        fun isLogin_False() {
            fakeTimeLogger()

            val sut = LobbyAction()
            assertTrue {
                sut.isInLobby()
            }
        }

    }
    ```


### 繼承 - 類別模板

* 另外我們時常會在源碼中使用繼承的技巧，來避免我們重複的寫一樣的 Code；但是在測試時我們仍要測試父類重複的 Function；這時就可以使用繼承模板技巧

    ```kotlin=
    interface IBaseStrParser {
        fun trim(str: String) : String

    }

    open class BaseStrParserImpl : IBaseStrParser {

        // 待測試
        override fun trim(str: String): String {
            return str.trim().replace("\n", "")
        }

    }

    class XmlStrParse : BaseStrParserImpl() {

        fun getFirstTag(str: String) : String {
            // 仍要測試到父類 trim

            return trim(str).let {
                it.first().toString()
            }
        }

    }

    class JsonStrParse : BaseStrParserImpl() {

        fun getObjectArray(str: String) : Array<out String> {
            // 仍要測試到父類 trim

            return trim(str).let {
                arrayOf(it)
            }
        }

    }
    ```
    :::info
    * 父類的相同方法也要測試嘛？
        
        這取決於子類是否有呼叫到父類的基礎方法；從上面我們可以看出來每個子類都會呼叫 `trim` 方法，並且透過該方法返回的值再進行操作，所以需要測試
    :::
    

* 創建一個抽象類，撰寫 `trim` 測試函數，並創建幾個方法，讓子類繼承後只須填入數值、待測試物件就可測試

    ```kotlin=
    abstract class BaseStrParserTests {

        companion object {
            const val EXPECT_STR = "123"
        }

        lateinit var sut : IBaseStrParser

        @BeforeEach
        fun setup() {
            // 在每個測試之前初始化待測物件
            sut = getSUT()
        }

        // 由父類統一測試
        @Test
        fun trim_space_equals() {
            assertEquals(EXPECT_STR, sut.trim(test_trim_space()))
        }

        // 由父類統一測試
        @Test
        fun trim_next_equals() {
            assertEquals(EXPECT_STR, sut.trim(test_trim_nextLine()))
        }

        abstract fun test_trim_space() : String
        abstract fun test_trim_nextLine() : String
        
        // 由子類來定義當前要測試的物件
        abstract fun getSUT() : 

    }

    class BaseStrParserImplTest : BaseStrParserTests() {
        
        // 透過繼承 子類不會直接寫到測試，只須填入待測試的數值
        override fun test_trim_space(): String {
            // 填入符合測試狀況的值
            return " $EXPECT_STR "
        }

        override fun test_trim_nextLine(): String {
            // 填入符合測試狀況的值
            return "$EXPECT_STR\n"
        }


    }
    ```

### 繼承 - 泛型

* 依照上一個範例，可以透過泛型得知子類的實做、並透過反射創建對應的待測試類（SUT），最終進一步省略 `getSUT()` 方法

    :::warning
    但由於 JVM 會對泛型字節碼進行擦除動作，所以我們仍然需要子類傳入 KClass 幫助反射創建
    :::

    ```kotlin=
    abstract class BaseStrParserTests2<T: IBaseStrParser>(private val parserClass: KClass<T>) {

        companion object {
            const val EXPECT_STR = "123"
        }

        private val sut: T by lazy { parserClass.createInstance() }

        @Test
        fun trim_space_equals() {
            assertEquals(EXPECT_STR, sut.trim(test_trim_space()))
        }

        @Test
        fun trim_next_equals() {
            assertEquals(EXPECT_STR, sut.trim(test_trim_nextLine()))
        }

        abstract fun test_trim_space(): String
        abstract fun test_trim_nextLine(): String

    }

    class BaseStrParserImplTest2 : BaseStrParserTests2<BaseStrParserImpl>(BaseStrParserImpl::class) {
        override fun test_trim_space(): String {
            return " $EXPECT_STR "
        }

        override fun test_trim_nextLine(): String {
            return "$EXPECT_STR\n"
        }
    }
    ```
    > ![](https://i.imgur.com/sNdnxny.png)

## Appendix & FAQ

:::info
:::

###### tags: `Test`
