測試與程式碼品質

TDD 測試導向開發、Red–Green–Refactor 迴圈的真實面貌(實務心得)

「要先寫測試?程式碼都還沒有,是要測試什麼?」

閱讀 3 分鐘
TDD 測試導向開發、Red–Green–Refactor 迴圈的真實面貌(實務心得) 封面圖

「要先寫測試?程式碼都還沒有,是要測試什麼?」

第一次接觸 TDD(Test-Driven Development,測試導向開發)時,任何人都會這麼想。順序看起來反了,還會覺得無緣無故把工作量增加了一倍。

不過,實際投入幾個月後,想法會稍微改變。

TDD 是一種反覆執行極短迴圈的開發方式:「失敗的測試(Red)→ 讓測試通過的最少程式碼(Green)→ 整理(Refactor)」。重點是以幾秒到幾分鐘的速度快速跑完這個迴圈。

今天就來一步步看看這個 Red–Green–Refactor 迴圈實際上如何運作。


TDD Red–Green–Refactor:3 個步驟重點整理 ✍️

先讓你一眼看懂完整迴圈。

  1. Red:先為尚不存在的功能撰寫測試。當然會失敗。
  2. Green:只撰寫能讓測試通過的最少程式碼。不必漂亮。
  3. Refactor:維持測試通過的狀態,同時整理程式碼。

把這 3 個步驟視為一個整體,不斷重複即可。

不斷繞著這 3 個步驟執行,就是 TDD 的全部
不斷繞著這 3 個步驟執行,就是 TDD 的全部

重要的是不要把每個步驟訂得太大。不要一次處理「完整的登入功能」,而是拆成「電子郵件為空時回傳錯誤」這種小單位。

如果一個迴圈要花 30 分鐘,那很可能只是搭配測試的開發,而不是 TDD。


Red 階段,為什麼要故意撰寫會失敗的程式碼?

這是初學 TDD 時最難理解的部分。明知道會失敗,為什麼還要執行?

原因很簡單。為了確認這個測試「確實會失敗」。

如果測試一寫好就通過,實際上它可能只是什麼都沒驗證的空殼。先看到失敗,等於是在測試測試本身。

舉個簡單的例子,假設要建立一個將兩個數字相加的函式。會先出現測試,再出現程式碼。

// add 函式尚不存在。因此這個測試會失敗(Red)
struct AddTests {
    @Test func 加法_1相加2是3() {
        #expect(add(1, 2) == 3)
    }
}

在這個狀態下執行,會出現像「cannot find ‘add’ in scope」這樣的錯誤。反而是看到這個紅燈,會讓我更安心,因為目標變清楚了。

從現在開始,我要做的只有一件事:把紅燈變成綠燈。

看到紅燈,反而能清楚知道接下來要做什麼
看到紅燈,反而能清楚知道接下來要做什麼

Green 與 Refactor,真正的實力就在這裡分出高下

Green 階段的原則是「簡單到令人不好意思」。

讓前面測試通過的最快程式碼就是這樣。

// 讓測試通過所需的最少程式碼(Green)
func add(_ a: Int, _ b: Int) -> Int {
    return a + b
}

看起來很理所當然吧?但越是初學者,越容易在這裡擔心未來,提前加入例外處理、記錄與設定值。

TDD 會刻意讓你忍住這個衝動。規則就是:不要撰寫目前測試沒有要求的程式碼。

等綠燈亮起後,才進入 Refactor。這時會調整變數名稱、移除重複內容。

重點是,Refactor 期間絕對不新增功能。只在維持測試通過的狀態下改善結構。

如果整理時亮起紅燈呢?把剛才修改的內容還原就好。測試會保證它不久前還能正常運作。

多虧這個安全網,我重構時敢大膽得多。

就是因為能把紅燈變成綠燈的快感,才會一直做下去
就是因為能把紅燈變成綠燈的快感,才會一直做下去

使用 TDD,開發不會變慢嗎? 🤔

這是最常被問到的問題。實際使用後,我的答案是:「要看情況。」

我把自己感受到的差異整理成表格。

分類 套用 TDD 之後再撰寫測試
初期速度 稍慢
發現錯誤的時機 撰寫後立即 過了很久之後
重構負擔
複雜邏輯 有利 不利

老實說,快速做出一個單純畫面時,TDD 有時會讓人覺得礙手礙腳。

相反地,在付款、結算、折扣計算等條件交錯的邏輯中,TDD 確實發揮了價值。用測試逐一鎖定各種情況後,即使之後大幅修改程式碼,也不再害怕。

最後,TDD 不是萬靈丹,而更像是「駕馭複雜度的工具」。


總結

如果覺得 TDD 很難,別一開始就設定得太宏大,先從一個函式跑一次 Red–Green–Refactor 吧。

當小小的成功迴圈成為習慣,就會稍微理解為什麼人們無法放棄這種方法。今天就從一個測試開始點亮紅燈吧。

延伸閱讀