if got, want:写出更好 Go 测试的简单方法
原文由 Michael Lynch 于 发布,订阅该博客
有一个非常棒的 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 语句作用域内声明的变量,只在该语句内部有效。正因如此,你可以在所有断言中反复使用 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 标准库中偶尔出现的这种用法中学到的。
随机一篇博客
评论
登录后参与讨论