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 := 模式在表格驱动测试中效果更佳。以下是我用于解析社交媒体账号的库中的一个示例:
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 标准库中偶尔出现的用法中学到的。
随机一篇博客