在 Day 13,我們為字串計算機打下了堅實的基礎,成功地透過兩個快速的 TDD 循環處理了「空字串」和「單一數字」的情況,我們甚至還體驗了一次 TDD 如何防止我們進行不正確的重構。
到目前為止,我們的計算機還名不副實——它從未進行過任何「計算」,今天,我們將讓 Add 函式學會處理多個數字。
今天的目標:透過 TDD 循環,讓我們的計算機學會處理:
我們的下一個需求是:「對於兩個用逗號分隔的數字,它將回傳它們的和」,我們再次回到 stringcalc/stringcalc_test.go,在測試表格中加入這個新的案例:
// in stringcalc_test.go, within testCases
{
name: "should return the number itself for a single number",
input: "1",
expected: 1,
},
// 新增案例
{
name: "should return the sum of two comma-separated numbers",
input: "1,2",
expected: 3,
},
執行測試 (go test -v ./...),毫不意外,紅燈亮起: panic: strconv.Atoi: parsing "1,2": invalid syntax
這次的失敗報告和之前不同。它不是一個 assert 錯誤,而是一個 panic(恐慌),這是因為我們現有的程式碼 strconv.Atoi("1,2") 無法將包含逗號的字串轉換為整數,於是拋出了異常。這個 panic 精準地告訴我們,現有邏輯無法處理新需求。
為了處理 "1,2",我們需要做兩件事:
["1", "2"]
。Go 的標準函式庫 strings
提供了 Split
函式,正好能完成第一步,讓我們來修改 stringcalc/stringcalc.go:
package stringcalc
import (
"strconv"
"strings" // 導入 strings
)
func Add(numbers string) (int, error) {
if numbers == "" {
return 0, nil
}
// 使用 strings.Split 處理逗號
parts := strings.Split(numbers, ",")
if len(parts) == 1 {
return strconv.Atoi(parts[0])
}
// 處理兩個數字的情況
num1, _ := strconv.Atoi(parts[0])
num2, _ := strconv.Atoi(parts[1])
return num1 + num2, nil
}
這個實作非常「直接」,甚至有點「笨拙」,我們明確地處理了 len(parts) == 1
的情況,然後寫死了處理 parts[0]
和 parts[1]
的邏輯,我們暫時忽略了 Atoi
可能回傳的 error,因為我們的測試案例都是合法的。
這完全符合 TDD 的「綠燈」階段精神:用最簡單、甚至有些討巧的方式讓測試通過。
我們的產品程式碼現在可以工作了,但它存在明顯的壞味道:它只能處理一個或兩個數字,無法處理三個或更多,num1, _ := ...
, num2, _ := ...
這樣的程式碼重複性很高。
在測試的保護下,我們可以自信地將其重構為一個更通用的、基於迴圈的解決方案,重構後的 stringcalc/stringcalc.go
package stringcalc
import (
"strconv"
"strings"
)
func Add(numbers string) (int, error) {
if numbers == "" {
return 0, nil
}
parts := strings.Split(numbers, ",")
sum := 0
// 使用迴圈來處理任意數量的數字
for _, part := range parts {
num, err := strconv.Atoi(part)
if err != nil {
// 暫時忽略錯誤處理
return 0, err
}
sum += num
}
return sum, nil
}
這個版本優雅多了!它不再關心到底有幾個數字,不管是 1 個、2 個還是 10 個,這個迴圈都能正確處理。 執行測試,綠燈依然亮著! 我們的重構是成功的。
我們的重構實際上已經「預見」並「實現」了這個需求,但我們應該用一個明確的測試案例來「鎖定」這個功能。
在測試表格中增加這個更複雜的案例:
// in stringcalc_test.go, within testCases
{
name: "should return the sum of two comma-separated numbers",
input: "1,2",
expected: 3,
},
// 新增案例
{
name: "should return the sum of multiple comma-separated numbers",
input: "1,2,3,4,5",
expected: 15,
}
執行測試,你會發現一個有趣的情況:測試直接就通過了!
這是一個 「快樂的意外」,這意味著我們在上一個循環的重構階段,選擇了一個足夠通用的設計,它不僅滿足了當時的需求,還順便滿足了下一個需求。
在 TDD 中,這是一個非常積極的信號,證明我們的設計走在正確的軌道上,即便如此,這個新的測試案例依然是極其重要的。它將「處理多個數字」這個功能,從一個「意外的副作用」變成了一個「被明確定義和保護的需求」。從此以後,任何破壞這個功能的修改,都會被這個測試案例捕捉到。
新需求: 允許使用換行符 \n
和逗號 , 作為分隔符,我們在 stringcalc_test.go 中加入新的測試案例。
// in testCases
// 新增案例
{
name: "should handle new lines between numbers",
input: "1\n2,3",
expected: 6,
},
執行測試 (go test -v ./...),紅燈亮起,現有的 strings.Split(numbers, ",")
無法處理 \n
。
我們不能再只用逗號來分割了。一個簡單的想法是,先把所有的 \n
都替換成 ,
,然後再用老辦法 Split。
package stringcalc
import (
"strconv"
"strings"
)
func Add(numbers string) (int, error) {
if numbers == "" {
return 0, nil
}
// 先將換行符替換為逗號
processedString := strings.ReplaceAll(numbers, "\n", ",")
parts := strings.Split(processedString, ",")
sum := 0
for _, part := range parts {
num, err := strconv.Atoi(part)
if err != nil {
return 0, err
}
sum += num
}
return sum, nil
}
執行測試,綠燈!這個小小的重構(引入 processedString)非常有效。程式碼意圖清晰,無需進一步重構。
新需求: 支援自訂分隔符。格式為 //[delimiter]\n[numbers...]
。
這個需求引入了全新的格式。我們新增測試案例:
{
name: "should support a custom delimiter",
input: "//;\n1;2",
expected: 3,
},
執行測試,紅燈亮起!我們的程式碼會把 //;
當成數字,導致 Atoi 失敗。
我們需要先檢查字串是否以 //
開頭,如果是,我們就解析出新的分隔符和真正的數字部分;如果不是,就還用老方法。
// in stringcalc.go
func Add(numbers string) (int, error) {
if numbers == "" {
return 0, nil
}
delimiter := ","
numbersPart := numbers
// 檢查是否有自訂分隔符
if strings.HasPrefix(numbers, "//") {
parts := strings.SplitN(numbers, "\n", 2)
delimiter = strings.TrimPrefix(parts[0], "//")
numbersPart = parts[1]
}
processedString := strings.ReplaceAll(numbersPart, "\n", delimiter)
parts := strings.Split(processedString, delimiter)
sum := 0
for _, part := range parts {
// ... (summation logic remains the same)
num, _ := strconv.Atoi(part)
sum += num
}
return sum, nil
}
這段程式碼很巧妙地分離了「配置解析」和「數字計算」兩個關注點。執行測試,綠燈!程式碼的職責劃分已經很清晰了,無需進一步重構。
新需求: 不允許傳入負數,如果傳入了,函式應回傳一個包含所有負數的錯誤訊息。
這次我們需要測試錯誤,這是一個很好的機會來寫一個獨立的測試函式,專門用來驗證錯誤情境。
// in stringcalc_test.go
func TestStringCalculator_Add_WithNegatives(t *testing.T) {
input := "1,-2,3,-4"
// 呼叫函式
_, err := Add(input)
// 斷言:我們期望一個錯誤
require.Error(t, err, "an error should be returned for negative numbers")
// 斷言:錯誤訊息應該包含所有負數
assert.EqualError(t, err, "negatives not allowed: -2, -4")
}
執行測試,紅燈亮起,我們現有的程式碼會正常回傳 -2
,err 是 nil
,require.Error
會失敗。
我們需要在加總的迴圈中,檢查數字是否為負。如果發現負數,我們應該把它們收集起來,而不是直接加總。
// in stringcalc.go
func Add(numbers string) (int, error) {
// ... (delimiter parsing logic remains the same)
processedString := strings.ReplaceAll(numbersPart, "\n", delimiter)
parts := strings.Split(processedString, delimiter)
sum := 0
var negatives []string // 用來收集負數
for _, part := range parts {
num, _ := strconv.Atoi(part)
if num < 0 {
negatives = append(negatives, strconv.Itoa(num))
continue // 發現負數,加入列表,繼續下一個
}
sum += num
}
// 如果有負數,格式化錯誤並回傳
if len(negatives) > 0 {
return 0, fmt.Errorf("negatives not allowed: %s", strings.Join(negatives, ", "))
}
return sum, nil
}
執行測試,所有測試,包括新的錯誤測試,全部通過!一片綠燈!
我們的程式碼現在既健壯又優雅。它清晰地分離了職責:解析輸入、遍歷處理、報告錯誤。這就是 TDD 引導我們達到的良好設計。
今天我們經歷了一場需求的「閃電戰」,並在 TDD 的引導下取得了全面的勝利。我們不僅完成了字串計算機的所有核心需求,更重要的是,我們見證了一個簡單的函式是如何演進成一個健壯、設計良好的微型解析器。
panic
失敗。我們的 TDD 手動實踐部分到此告一段落。你已經具備了利用 TDD 開發健壯軟體的堅實基礎。
預告:第三階段正式開啟!Day 15 - TDD 實戰回顧 - 我們從 Kata 中學到了什麼?