if got, want:撰寫更佳 Go 測試的簡單方法
有一個非常優秀的 Go 測試模式,但知道的人太少了。我只要 30 秒就能教會你。
別再這樣寫 Go 測試:
// The common, unrefined way.
username := GetUser()
if username != "dummyUser" {
t.Errorf("unexpected username: got %s, want: %s", username, "dummyUser")
}請改成這樣寫,讓每個斷言都以 if got, want := 開頭:
// The underused, elegant way.
if got, want := GetUser(), "dummyUser"; got != want {
t.Errorf("username=%s, want=%s", got, want)
}if got, want := 模式在 table-driven tests(表格驅動測試) 中效果更好。以下範例來自我用於解析社群媒體帳號的函式庫:
func TestParseTwitterHandle(t *testing.T) {
for _, tt := range []struct {
explanation string
input string
handleExpected social.TwitterHandle
errExpected error
}{
{
"regular handle on its own is valid",
"jerry",
social.TwitterHandle("jerry"),
nil,
},
{
"regular handle in URL is valid",
"https://twitter.com/jerry",
social.TwitterHandle("jerry"),
nil,
},
{
"handle with exactly 15 characters is valid",
"https://twitter.com/" + strings.Repeat("A", 15),
social.TwitterHandle(strings.Repeat("A", 15)),
nil,
},
{
"handle with more than 15 characters is invalid",
"https://twitter.com/" + strings.Repeat("A", 16),
social.TwitterHandle(""),
social.ErrInvalidTwitterHandle,
},
} {
t.Run(fmt.Sprintf("%s [%s]", tt.explanation, tt.input), func(t *testing.T) {
handle, err := social.ParseTwitterHandle(tt.input)
if got, want := err, tt.errExpected; got != want {
t.Fatalf("err=%v, want=%v", got, want)
}
if got, want := handle, tt.handleExpected; got != want {
t.Errorf("handle=%v, want=%v", got, want)
}
})
}
}這個模式如何運作?
Go 中簡單的 if 陳述式會評估一個布林運算式:
// A simple if statement that evaluates a boolean expression.
if volume > maxVolume {
volume = maxVolume
}Go 提供了第二種 if,讓你可以在評估布林運算式之前先執行一個陳述式:
// Execute a statement before evaluating the boolean expression.
if volume := getRequestedVolume(); volume > maxVolume {
panic("requested volume is too high")
}巧妙之處在於,你可以在 if 陳述式中同時宣告並賦值多個變數:
// Declare and assign multiple variables within if statement.
if a, b, c := nextScore(), nextScore(), nextScore(); a + b + c == 300 {
fmt.Println("Congratulations! You got a perfect score!")
}在 if 陳述式的作用域內宣告的變數,只會存在於該 if 陳述式中。這就是為什麼你可以在所有斷言中重複使用 got 和 want 這兩個變數名稱,而不會造成命名衝突。
事實上,如果你嘗試在 if 陳述式之外存取 got 或 want,Go 編譯器會告訴你該變數不存在:
// got and want are only available within the if statement.
if got, want := GetUser(), "dummyUser"; got != want {
t.Errorf("username=%s, want=%s", got, want)
}
log.Printf("username was %s", got) // This won't compile這個技巧好在哪裡?
它能訓練你的眼睛快速找到重要資訊
Go 程式碼往往相當冗長,尤其是在測試中。
試想以下測試片段:
users := GetAllUsers()
if len(users) != 1 {
t.Fatalf("expected only a single user, got %d", len(users))
}
if users[0].username != adminUsername {
t.Errorf("unexpected username: got %s, want: %s", users[0].username, adminUsername)
}乍看之下,你能清楚分辨哪些是我預期的值、哪些是 GetAllUsers 回傳的值嗎?對我來說並不明顯。
如果我用 if got, want := 模式重寫上述片段,這種模糊性就會消失:
users := GetAllUsers()
if got, want := len(users), 1; got != want {
t.Fatalf("userCount=%d, want=%d", got, want)
}
if got, want := users[0].username, adminUsername; got != want {
t.Errorf("username=%s, want: %s", got, want)
}一旦你熟悉這個模式,就能快速在測試斷言中找到重要資訊:

當你認得這個模式時,你的眼睛就能快速找到斷言中的實際值與預期值。
複製/貼上很方便
當變數永遠都命名為 got 和 want 時,你可以複製/貼上斷言而不需要修改太多地方。通常只需要修改賦值、t.Errorf 中的名稱,或許還有格式指定字元(例如 %s 與 %v)。
這個模式也能避免我過去常犯的一種錯誤:複製/貼上測試斷言後,卻忘了更新錯誤訊息的某個部分,例如這樣:
username := GetUser()
if username != "admin" {
t.Errorf("wrong username: got %s, want %s", username, "admin")
}
email := GetEmail()
if email != "[email protected]" {
// Whoops, copy/pasted from above but forgot to update the error message.
t.Errorf("wrong username: got %s, want %s", username, "admin")
}直到測試失敗、產生這樣令人困惑的錯誤訊息,我才會發現這個錯誤:
--- FAIL: TestUserProperties (0.00s)
users_test.go:24: wrong username: got admin, want adminif got, want := 模式能幫我避免這類錯誤,因為當我複製/貼上測試斷言時,只需要在一個地方更新數值。
它能區分測試斷言與測試邏輯
當我在 Go 中實作 HTTP 伺服器時,經常會寫出像這樣的單元測試:
func TestUserHandler(t *testing.T) {
for _, tt := range []struct {
explanation string
payload string
statusExpected int
responseExpected string
}{
{
"valid request returns success",
"username=doug",
http.StatusOK,
"created user doug",
},
{
"reject username with angle brackets",
"username=d<script>oug",
http.StatusBadRequest,
"",
},
{
"reject empty username",
"username=",
http.StatusBadRequest,
"",
},
} {
t.Run(tt.explanation, func(t *testing.T) {
req, err := http.NewRequest("POST", "/user", strings.NewReader(tt.payload))
if err != nil {
t.Fatal(err)
}
s := NewServer()
rec := httptest.NewRecorder()
s.Router().ServeHTTP(rec, req)
res := rec.Result()
if got, want := res.StatusCode, tt.statusExpected; got != want {
t.Fatalf("httpStatus=%v, want=%v", got, want)
}
// If this is not a test for valid input, ignore the rest of the
// server's response.
if tt.statusExpected != http.StatusOK {
return
}
body, err := io.ReadAll(res.Body)
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if got, want := string(body), tt.responseExpected; got != want {
t.Fatalf("response=%s, want=%s", got, want)
}
})
}
}在該測試主體中,有兩種不同類型的 if 陳述式:測試斷言與測試邏輯分支。
每個使用 if got, want := 模式的 if 陳述式,都是針對我正在測試的程式碼所做的斷言。其他所有的 if 陳述式都只是控制流程,並非針對我的程式碼的斷言。
例如,測試中的第一個 if 陳述式是用來檢查我是否成功建立了 HTTP 請求物件:
req, err := http.NewRequest("POST", "/user", strings.NewReader(tt.payload))
if err != nil {
t.Fatal(err)
}這並非針對我的程式碼的斷言,因為我甚至還沒呼叫自己的伺服器。如果這段程式碼失敗了,表示 Go 標準函式庫中發生了某些異常狀況。
另一方面,每當讀者看到 if got, want :=,就能確定我正在對自己測試的程式碼進行斷言:
if got, want := res.StatusCode, tt.statusExpected; got != want {
t.Fatalf("httpStatus=%v, want=%v", got, want)
}為什麼不使用第三方測試斷言函式庫?
如果你是像 testify 或 is 這類第三方測試函式庫的忠實使用者,這篇文章聽起來可能會很荒謬。那些函式庫同時提供了表達性強的測試輸出與清晰的斷言,那麼我為什麼不用它們呢?
我是從 Python 轉來寫 Go 的,所以當時覺得 Go 竟然沒有提供像 Python 的 unittest.assertEqual 這樣的 API 實在很荒謬。我立刻就想使用第三方函式庫來建立 mock 並進行斷言,但經驗更豐富的同事們建議我改用 Go 標準函式庫的測試 API 試試看。
後來我反而更偏好 Go 標準測試函式庫的極簡與明確,而非第三方函式庫。那些函式庫是需要額外維護的依賴,也多了一層可能引入錯誤的抽象層。
致謝
這個技巧是我從 Litestream 的作者 Ben Johnson(班·強森) 那裡學來的,而他則是從 Go 標準函式庫中偶爾出現的用法學到這個技巧的。
隨機一篇部落格