---
# System prepended metadata

title: TDD 測試驅動開發
tags: [Test]

---

---
title: 'TDD 測試驅動開發'
disqus: kyleAlien
---

TDD 測試驅動開發
===

## Overview of Content

TDD (Test-driven development) 測試驅動開發，簡單來說就是 **先寫測試再寫程式 (可以說是測試為主體，程式是為了滿足測試)；這樣先寫測試有甚麼優點呢 ?**

[TOC]

## TDD 概述

我們也知道 **測試案例需要從失敗案例開始撰寫**；TDD 也是遵循這個概念，所以 TDD 在撰寫時有以下固定的步驟

1. **紅燈**：依照需求，先撰寫失敗案例

2. **綠燈**：撰寫剛好可以通過測試的程式（滿足以測試為主體）

3. **重構**：在不更改需求的情況下，重構程式

    > 這裡的重構是指優化程式，而不修改邏輯 (例如可以讓程式看起來更有可讀性... )

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

### TDD 關注重點

* 在使用 TDD 開發程式時我們可以發現以下特點，我們也要在撰寫程式時同時去思考、顧及以下這幾個點 (如同 OOP 的六大基礎概念)
    

    | TDD 特點 | 優點 | 說明 |
    | -------- | -------- | - |
    | 小步快跑 | **一次只關注一件事** | TDD 的開發方式非常滿足敏捷開發的概念，小量快速迭代，有問題時可以快速解決 (大量會導致不易追蹤) |
    | 維持綠燈 | 確保程式的穩定性 |  |
    | 先分析再測試 | **仔細思考需求** | 由於我們在撰寫單元測試時，會將方法切分才可以測試，如果這時 **先寫測試就可以先去思考如何拆分需求 (函數)** |
    | 僅在測試失敗時撰寫新的程式 | 如同寫測試時，先寫錯誤案例一樣 |  |
    | 重構 | 讓程式碼更簡潔、或更有可讀性 | 重構看似可有可無，不過這對於優質程式是相當重要的 |


### TDD 無法作到的事情

* TDD 簡單來說就是測試先行，但僅僅是測試先行我們也不能保證以下的事情

    1. 無法保證測試是可維護、可讀、可靠的
    
    2. 如果你作到了測試的可維護、可讀、可靠，也不能保證你能獲得測試先行的好處
    
    3. 如果你作到了測試的可維護、可讀、可靠，並不能保證你設計出一個可靠、穩定的系統

    :::success
    簡單來說，測試能力並不等同於你的程式設計能力
    :::

## TDD 開發

接著我們會以一個簡單的案例來示範 TDD 要如何去開發

:::info
* 需求案例
    
    **目標**：計算吃火鍋花費的價格
    
    **條件**：內用 300 元，外帶打 9 折
:::

### 撰寫失敗案例 - 紅燈

* 首先思考目標、條件

    1. 目標是要驗證價格

    2. 由於這裡有一個條件，所以們我需要寫兩個單元測試，而這個條件我選用 DI 注入接口


* 驗證內用的價格
    
    1. **使用 `mockk` 來創建假物件**：這裡我們就需要一個　`IUseType` interface　（DI 注入這個 ）

        ```kotlin=
        @Test
        fun test_HotPotCost_ForHere() {
            val stubForHere = mockk<IUseType>()

            every {
                stubForHere.forHere()       
            }.returns(true)            // 目前案例是寫內用，所以返回 true
        }
        ```
        
        :::success
        * 可以使用 Android Studio 的快捷鍵 (`Alt` + `Enter`) 來快速創建類
            
            1. 創建 interface 
                > ![](https://i.imgur.com/N7g4mX1.png)
            
            2. 指定檔案路徑
                > ![](https://i.imgur.com/1sdJK7b.png)
            
            3. 選擇指定路徑
                > ![](https://i.imgur.com/FzcS0ey.png)
            
            4. 用同樣的快捷鍵創建 `forHere` 函數
                > ![](https://i.imgur.com/MQG7cTm.png)

        :::

    2. **創建 HotPot 類、靜態成員、待測方法**：也就是 SUT 類 & 方法 (待測目標)
        
        > 同樣使用上面的快捷鍵方法創建

        ```kotlin=
        @Test
        fun test_HotPotCost_ForHere() {
            val stubForHere = mockk<IUseType>()
            every {
                stubForHere.forHere()
            }.returns(true)


            val exceptCost = HotPot.DEFAULT_COST_300        // 靜態成員

            val sut = HotPot()           // 創建 SUT 類

            val realCost = sut.getCost(stubForHere)        // 待測方法

            assertEquals(exceptCost, realCost)
        }
        ```
        
        關於 SUT 的待測方法，先不去實做，在之後的綠燈通行才去實作它
        
        ```kotlin=
        // SUT 對象

        class HotPot {

            fun getCost(stubForHere: IUseType) : Int {
                TODO("Not yet implemented")
            }

            companion object {
                const val DEFAULT_COST_300: Int = 300
            }

        }

        ```

    3. 運行測試：預計得到一個錯誤結果 "Not yet implemented"
        
        > ![](https://i.imgur.com/nohkc47.png)
    
* 驗證外帶的價格：TDD 運行方式如上，只需要修改 `forHere`，返回 false 即可；同樣要驗證出錯誤
    ```kotlin=
    @Test
    fun test_HotPotCost_TakeOut() {
        val stubForHere = mockk<IUseType>()
        every {
            stubForHere.forHere()
        }.returns(false)


        val exceptCost = HotPot.DEFAULT_COST_300

        val sut = HotPot()

        val realCost = sut.getCost(stubForHere)

        assertEquals((exceptCost* 0.9).toInt(), realCost)
    }
    ```
    > ![](https://i.imgur.com/nohkc47.png)

### 通過測試案例 - 綠燈

* 當測試案例寫完後 (目前是錯誤 **不通過的狀態**)，我們就要來修改程式，讓其恰好可以通過測試案例

    ```kotlin=
    class HotPot {

        // 準備修改這個函數，讓其通過測試
        fun getCost(stubForHere: IUseType): Int {
            TODO("Not yet implemented")
        }

        companion object {
            const val DEFAULT_COST_300: Int = 300
        }

    }
    ```

* **撰寫滿足測試的程式**：`getCost` 函數修改邏輯後如下

    ```kotlin=
    class HotPot {

        fun getCost(stubForHere: IUseType): Int {
            // 撰寫滿足測試的程式
            if (stubForHere.forHere()) {
                return DEFAULT_COST_300
            }

            return (DEFAULT_COST_300 * 0.9).toInt()
        }

        companion object {
            const val DEFAULT_COST_300: Int = 300
        }

    }
    ```
    
* 運行兩個測試案例，這時可以發現測試都通過；我們也就以最小的步伐去滿足了需求 (小步快跑)

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

    :::warning
    如果測試錯誤的話就必須回頭去觀察，是需求的測試出了問題，還是實現的程式出了問題
    :::

### 程式優化 - 重構

* 最後當測試通過、程式也完成後，可以在針對程式做優化 
    ```kotlin=
    // 以下修正讓程式更簡潔

    fun getCost(stubForHere: IUseType): Int {

        return if (stubForHere.forHere()) {
            DEFAULT_COST_300
        } else {
            (DEFAULT_COST_300 * 0.9).toInt()
        }
    }
    ```

    **優化完後記得在運行一次測試，確保沒有修改到程式邏輯** (有可能會不小心改錯)

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

## Appendix & FAQ

:::info
:::

###### tags: `Test`
