Go 語言中常見問題 - 忽視表驅動測試
表驅動測試是編寫精簡測試的一種有效技術。它減少了樣板代碼(具有固定模式的代碼塊,冗餘但是又不得不寫),幫助我們更加專注於重要的事情:測試邏輯。本文將通過一個具體的例子來說明爲什麼使用表驅動測試值得我們瞭解。
下面函數實現的功能是將給定字符串的後綴 \ n 或 \ r\n 全部刪除,直到末尾不含換行符 \ n 或 \ r\n 終止。
func removeNewLineSuffixes(s string) string {
if s == "" {
return s
}
if strings.HasSuffix(s, "\r\n") {
return removeNewLineSuffixes(s[:len(s)-2])
}
if strings.HasSuffix(s, "\n") {
return removeNewLineSuffixes(s[:len(s)-1])
}
return s
}
上面函數採用了遞歸實現。現在,假設我們要全面地測試這個函數,至少要覆蓋以下幾種情況:
-
輸入的是空串
-
輸入的字符串以 \ n 結尾
-
輸入的字符串以 \ r\n 結尾
-
輸入的字符串以多個 \ n 結尾
-
輸入的字符串不含換行符
一種可能的方法是爲上面的每種輸入情況創建一個單元測試,代碼如下:
func TestRemoveNewLineSuffix_Empty(t *testing.T) {
got := removeNewLineSuffixes("")
expected := ""
if got != expected {
t.Errorf("got: %s", got)
}
}
func TestRemoveNewLineSuffix_EndingWithCarriageReturnNewLine(t *testing.T) {
got := removeNewLineSuffixes("a\r\n")
expected := "a"
if got != expected {
t.Errorf("got: %s", got)
}
}
func TestRemoveNewLineSuffix_EndingWithNewLine(t *testing.T) {
got := removeNewLineSuffixes("a\n")
expected := "a"
if got != expected {
t.Errorf("got: %s", got)
}
}
func TestRemoveNewLineSuffix_EndingWithMultipleNewLines(t *testing.T) {
got := removeNewLineSuffixes("a\n\n\n")
expected := "a"
if got != expected {
t.Errorf("got: %s", got)
}
}
func TestRemoveNewLineSuffix_EndingWithoutNewLine(t *testing.T) {
got := removeNewLineSuffixes("a\n")
expected := "a"
if got != expected {
t.Errorf("got: %s", got)
}
}
上述的每個測試函數都代表我們想要覆蓋的一個特定測試案例。觀察上述代碼,我們會注意到它存在兩個缺點。第一個很明顯的缺點是函數名稱變得很複雜,像 TestRemoveNewLineSuffix_EndingWithCarriageReturnNewLine 長達 55 個字符,閱讀會比較困難,會影響我們閱讀函數體內容。第二個缺點是這些函數存在重複的語句,因爲它們的結構是相同的,整個結構都是下面這樣。
-
調用 removeNewLineSuffixes 函數
-
定義預期結果值
-
對結果值進行比較
-
記錄錯誤信息
如果我們想要修改上面結構中的某個步驟,例如,將預期結果值作爲記錄錯誤信息的一部分,則不得不在所有測試函數中重複這個語句。並且編寫的測試用例越多,維護也就越困難。由於這些原因,我們可以使用表驅動測試,這樣只編寫一次邏輯即可。表驅動測試依賴於子測試,這意味着單個測試函數可以包含多個子測試。例如下面的測試包含兩個子測試:
func TestFoo(t *testing.T) {
t.Run("subtest 1", func(t *testing.T) {
if false {
t.Error()
}
})
t.Run("subtest 2", func(t *testing.T) {
if 2 != 2 {
t.Error()
}
})
}
上面的 TestFoo 函數包含兩個子測試,運行上述代碼,會輸出子測試 1 和子測試 2 的內容,具體顯示內容如下:
--- PASS: TestFoo (0.00s)
--- PASS: TestFoo/subtest_1 (0.00s)
--- PASS: TestFoo/subtest_2 (0.00s)
PASS
我們還可以使用 - run 參數運行單個測試,例如,如果只想運行 subtest 1, 可以將父測試名稱與子測試通過 / 連接起來賦值給 - run 參數,像下面這樣:
$ go test -run=TestFoo/subtest_1 -v
=== RUN TestFoo
=== RUN TestFoo/subtest_1
--- PASS: TestFoo (0.00s)
--- PASS: TestFoo/subtest_1 (0.00s)
現在回到本文開頭的例子,看看如何利用子測試來防止重複測試邏輯。實現思路是爲每個案例點創建一個子測試,定義一個 map 結構,map 的鍵代表測試名稱,map 的值代表測試數據的輸入值和預期值。實現代碼如下:
func TestRemoveNewLineSuffix(t *testing.T) {
tests := map[string]struct {
input string
expected string
}{
`empty`: {
input: "",
expected: "",
},
`ending with \r\n`: {
input: "a\r\n",
expected: "a",
},
`ending with \n`: {
input: "a\n",
expected: "a",
},
`ending with multiple \n`: {
input: "a\n\n\n",
expected: "a",
},
`ending without newline`: {
input: "a",
expected: "a",
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
got := removeNewLineSuffixes(tt.input)
if got != tt.expected {
t.Errorf("got: %s, expected: %s", got, tt.expected)
}
})
}
}
像上面這樣,使用包含測試數據的數據結構並利用子測試來避免重複代碼的做法正是表驅動測試的概念。上述代碼中的 tests 變量是一個 map,鍵是測試名稱,值表示測試數據。在此處的例子中,測試數據包含輸入和預期結果的字符串。map 中的每個元素都是我們想要覆蓋的測試用例。然後通過循環,爲每個測試用例運行一個新的子測試。
上面通過表驅動測試實現解決了前面測試代碼存在的兩個缺點:
-
每個測試名稱現在都是一個字符串,而不是 Pascal 命名法(首字母大寫,像 EndingWithCarriageReturnNewLine)函數名稱,方便閱讀。
-
測試邏輯只寫一次,所有的測試用例都共享它。後續如果添加新的測試用例,只需向結構體添加數據而不用動測試邏輯。
在 Go 語言中常見 100 問題 -#84 Not using test execution modes 中,討論了我們可以通過調用 t.Parallel 來標記並行運行的測試,我們也可以在提供給 t.Run 的閉包內的子測試中執行該操作,示例程序如下:
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()
// Use tt
})
}
但在使用表驅動測試的時候有一件事需要小心,稍不留意可能導致錯誤。就是在上面的閉包程序中使用了一個循環變量 tt, 導致閉包可能使用錯誤的 tt 變量值,爲了防止出現 Go 語言中常見 100 問題 -#63 Not being careful with goroutines and loop ... 中的問題,我們應該創建一個新的變量,將 tt 的值賦值給它, 像下面這樣,每個閉包都將訪問自己的 tt 變量。
for name, tt := range tests {
tt := tt
t.Run(name, func(t *testing.T) {
t.Parallel()
// Use tt
})
}
總結,如果多個單元測試具有相似的結構,我們可以使用表驅動對它們進行優化。這會帶給我們兩個好處,一是避免了大量重複邏輯,方便維護;二是可以輕鬆更改測試邏輯,添加新的測試用例也很容易。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/-UOG3Pn9aNxRMBtncGjF-w