Go 测试
照片来自 Antoine Dautry 在 Unsplash 上
测试是软件开发的重要部分,而在 Go 中编写测试很简单:
- 创建一个带有后缀
*_test.go
的文件 - 定义一个函数,签名为
TestXxx(t *testing.T)
- 使用
go test
命令运行它(或在 IDE 上直接点击按钮)。
但你是否曾考虑过你可以做更多呢?在本文中,我们将探讨更多的 Go 测试技巧,超越基础并帮助你编写更有效和可维护的测试。
处理失败标记
让我们首先讨论如何在 Go 中标记测试失败。除了简单地返回 t.Fail()
,还有几种方法可以实现。下面我会简要介绍不同的选项:
t.Fail()
:标记测试失败,但允许执行继续。t.FailNow()
:标记测试失败并立即停止其执行,使用runtime.Goexit()
。t.Errorf()
:结合使用 t.Logf() 记录错误消息和使用 t.Fail() 标记测试失败。t.Fatalf()
:结合使用 t.Logf() 记录错误消息和立即使用 t.FailNow() 标记测试失败。
如果你在一个测试函数中有多个测试用例不互相依赖,并且你不想停止执行但仍要标记测试失败,你可以使用 Errorf()
:
func TestAdd(t *testing.T) {
// case 1
if res := Add(1, 2, 3); res != 6 {
t.Errorf("Expected %d instead of %d", 6, res)
}
// case 2
if res := Add(-1, -2); res != -3 {
t.Errorf("Expected %d instead of %d", -3, res)
}
}
然而,如果你有一系列相互依赖的测试用例,并且你想要立即停止执行,如果其中任何一个失败,那么你可能需要考虑使用 t.Fatalf()
的不同方法:
func TestAdd(t *testing.T) {
// case 1
case1 := Add(1, 2, 3);
if case1 != 6 {
t.Fatalf("Expected %d instead of %d", 6, case1) // <- stop the execution
}
// case 2
case2 := Add(case1, -2);
if case2 != 4 {
t.Errorf("Expected %d instead of %d", 4, case2)
}
}
表格驱动测试
让我们简单地专注于单元测试一个典型的 Add
函数,它接收一个整数列表。在 TestAdd
函数中,我们将测试 2 种情况:
func Add(a ...int) int {
total := 0
for i := range a { total += a[i] }
return total
}
func TestAdd(t *testing.T) {
// case 1
if res := Add(1, 2, 3); res != 1+2+3 {
t.Errorf("Expected %d instead of %d", 1+2+3, res)
}
// case 2
if res := Add(-1, -2); res != -1-2 {
t.Errorf("Expected %d instead of %d", -1-2, res)
}
}
如果我们想要测试超过两个情况(10 个?),如果我们继续使用上述样式,代码将变得过于重复和繁琐。
相反,我们可以使用 表格驱动测试 方法,这种方法更有效和更易于阅读和编写:
func TestAdd(t *testing.T) {
testCases := []struct { args []int; want int} {
{args: []int{1, 2, 3}, want: 6},
{args: []int{-1, -2}, want: -3},
{args: []int{0}, want: 0},
{args: []int{-1, 2}, want: 1},
}
for _, tc := range testCases {
if res := Add(tc.args...); res != tc.want {
t.Errorf("Add(%v) = %d; want %d", tc.args, res, tc.want)
}
}
}
通过使用表格驱动方法,我们可以通过修改 testCases
数组(我是指匿名结构)来添加更多的测试用例。这有助于避免代码重复,并保持测试代码干净和易读,同时也允许我们轻松地检查所有测试用例。
嗯……这里有个问题。让我们使用 go test -v
命令运行测试以获取 详细 输出:
$ go test -v medium/write_test.go
=== RUN TestAdd
--- PASS: TestAdd (0.00s)
PASS
ok command-line-arguments 0.349s
我们无法从测试中获得太多信息,除了它是否失败或通过。此外,我们不知道有多少测试用例,或者哪个特定的测试用例失败了。让我们模拟一个测试用例失败的情况:
$ go test -v medium/write_test.go
--- FAIL: TestAdd (0.00s)
write_test.go:30: Add([0]) = 0; want 1
FAIL
当一个测试用例失败时,我们得到的输出并没有提供太多的信息供我们使用。我们无法确定哪个测试用例失败,或者它使用了哪个输入。
这时,子测试就派上用场了。通过利用子测试,我们可以将测试提升到更高的级别,并在出现问题时提供更详细和有用的信息。
子测试:运行多个测试用例
为了在 testing
包中使用子测试,我们需要熟悉一个名为 t.Run(name string, f func(t *testing.T)) (isSuccess bool)
的新函数。
t.Run()
使用给定的名称创建一个子测试,并在单独的 goroutine 中运行函数 f
。即使每个子测试都在自己的 goroutine 中运行,它们也是按顺序运行的。我们将学习如何使它们并行运行。
以下是我们如何在测试中使用它:
func TestAdd(t *testing.T) {
testCases := []struct { name string; args []int; want int}{
{name: "case 1 2 3", args: []int{1, 2, 3}, want: 6},
{name: "case -1 -2", args: []int{-1, -2}, want: -3},
{name: "case 0", args: []int{0}, want: 0},
{name: "case -1 2", args: []int{-1, 2}, want: 1},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
if res := Add(tc.args...); res != tc.want {
t.Errorf("Add(%v) = %d; want %d", tc.args, res, tc.want)
}
})
}
}
我添加了一个额外的字段‘name
’到 testCases
匿名结构中,它将用于命名子测试。为了看到区别,让我们运行 ‘go test -v
’ 命令。
$ go test -v medium/write_test.go
=== RUN TestAdd
=== RUN TestAdd/case_1_2_3
=== RUN TestAdd/case_-1_-2
=== RUN TestAdd/case_0
=== RUN TestAdd/case_-1_2
--- PASS: TestAdd (0.00s)
--- PASS: TestAdd/case_1_2_3 (0.00s)
--- PASS: TestAdd/case_-1_-2 (0.00s)
--- PASS: TestAdd/case_0 (0.00s)
--- PASS: TestAdd/case_-1_2 (0.00s)
PASS
ok command-line-arguments 0.523s
完美!现在我们的测试报告比以前清晰和详细得多。如果你愿意,你可以故意使一个子测试失败,看看子测试运行得有多好。
此外,你可以选择仅运行特定的测试,而不是运行所有测试,这可以节省大量时间,如果你只想测试某些功能的话。
$ go test -v -run=TestAdd/case_1_2_3 medium/write_test.go
=== RUN TestAdd
=== RUN TestAdd/case_1_2_3
--- PASS: TestAdd (0.00s)
--- PASS: TestAdd/case_1_2_3 (0.00s)
我注意到我们的每个测试用例都不依赖于其他测试用例(就像 失败标记 部分中的第二个示例)。为了加速我们的测试,我们可以使用 t.Parallel()
函数来并行运行这些测试用例。
并行运行子测试
要并行运行子测试,请使用 t.Parallel()
打开并行模式。当测试用例彼此独立时,这可能很有用,因为它可以使我们的测试运行得更快:
for _, tc := range testCases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel() // <---- mark this line
if res := Add(tc.args...); res != tc.want {
t.Errorf("Add(%v) = %d; want %d", tc.args, res, tc.want)
}
})
}
“嘿,我注意到循环内有行‘tc := tc’。它是做什么的?”
这解决了 Go 中闭包的一个问题。通过在循环内部创建一个名为 tc
的新局部变量,我们避免了与 for 循环的 init tc
变量的冲突。如果你仍然不确定发生了什么,请看一下下面的代码,以帮助说明这个概念:
func main() {
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i)
}()
}
time.Sleep(5 * time.Second)
}
“你期望控制台输出是什么样子的?它会显示 0 1 2 吗?”
在所有 goroutine 中,输出是“3 3 3”,因为每个 goroutine 的 fmt.Println()
语句都是在 for 循环完成后执行的。因此,i 的最终值在所有 goroutine 中都是 3。为了解决这个问题,你可以在for循环下的第一行添加“i := i
”这行代码。
其他单元测试概念
我们已经涵盖了大部分必要的概念,但如果你的需求更加复杂,我们有其他单元测试框架提供的类似功能,可以放心使用。
Helper
在开始之前,让我们看一下t.Helper()
函数的描述:“Helper将调用函数标记为测试辅助函数。在打印文件和行信息时,该函数将被跳过。”
最初,我不确定这个函数在我的生活中会有什么用处,因为我没有看到跳过任何函数的必要性。然而,当我开始开发一些测试工具,比如断言时,我开始欣赏这个函数在我的测试工作流中的重要性。
让我们考虑一个例子,我们需要使用AssertNil辅助函数来断言一个函数返回一个空错误。在这种情况下,我们的主要测试是TestF函数:
// AssertNil is our helper here
func AssertNil(t *testing.T, a any) {
if a != nil {
t.Errorf("expected nil but receive %v", a)
}
}
// ReturnNil will return nil but actually return an error (not nil)
func RetunrNil() error {
return errors.New("error: fake nil")
}
func TestF(t *testing.T) {
AssertNil(t, RetunrNil())
}
如果我们像之前一样执行“go test -v”命令,输出将如下所示:
go test -v medium/write_test.go
=== RUN TestF
write_test.go:10: expected nil but receive error: fake nil
--- FAIL: TestF (0.00s)
FAIL
FAIL command-line-arguments 0.518s
FAIL
“你遇到了什么问题?报告似乎运行得非常好。”
当然。看起来报告做得太好了,这就是问题所在。正如你所注意到的,错误消息被报告在第10行,这是在我们的辅助函数代码块t.Errorf(“expected nil but receive %v”, a)
内部。
但是当我编写辅助函数时,我不希望错误消息报告在辅助函数的代码块内。相反,我希望错误报告在调用辅助函数的确切代码行(在这种情况下是“AssertNil(t, RetunrNil())”
)。
幸运的是,t.Helper()
函数来拯救我们了。通过将此函数调用添加为我们的辅助函数的第一行,我们可以确保任何由辅助程序生成的错误都将在调用辅助函数的确切代码行报告:
func AssertNil(t *testing.T, a any) {
t.Helper()
if a != nil {
t.Errorf("expected nil but receive %v", a)
}
}
任何错误都将在测试函数中出现问题的确切代码行报告,例如我们在第20行调用AssertNil(t, RetunrNil())
的代码行。
go test -v medium/write_test.go
=== RUN TestF
write_test.go:20: expected nil but receive error: fake nil
--- FAIL: TestF (0.00s)
FAIL
FAIL command-line-arguments 0.498s
FAIL
Cleanup(清理)
在Go 1.14中,t.Cleanup()
功能可能有点令人困惑,就像t.Helper()
一样。这个函数允许你指定一个清理函数f,它将在测试完成后运行。
“我不能只用defer来做同样的事情吗?”
让我们仔细看一下。
// Using t.CleanUp(f)...
func TestAdd(t *testing.T) {
t.Cleanup(func() {
fmt.Println("CleanUp called")
})
if res := Add(1, 2); res != 3 {
t.Errorf("1 + 2 = 3 but receive %d", res)
}
fmt.Println("Done")
}
// using defer
func TestAddWithDefer(t *testing.T) {
defer func() {
fmt.Println("CleanUp called")
}()
if res := Add(1, 2); res != 3 {
t.Errorf("1 + 2 = 3 but receive %d", res)
}
fmt.Println("Done")
}
// ------- THEY ARE THE SAME
使用t.CleanUp
在传递了*testing.T
参数的函数中非常强大。
在下面的示例中,我将演示如何使用一个名为ConnectDB(t *testing.T)
的函数,该函数连接到数据库并返回一个连接。测试将使用连接来执行……可能是数据库上的CRUD操作:
type Connection struct{}
func ConnectDB(t *testing.T) Connection {
fmt.Println("ConnectDB")
t.Cleanup(func() {
fmt.Println("CloseDB")
})
return Connection{}
}
func TestDB(t *testing.T) {
connection := ConnectDB(t)
// ...
fmt.Println("do something with connection...", connection)
}
让我们运行测试,看看t.Cleanup()
如何实际工作:
go test -v medium/write_test.go
=== RUN TestDB
ConnectDB
do something with connection... {}
CloseDB
--- PASS: TestDB (0.00s)
PASS
当我们使用t.Cleanup()
时,清理过程将在整个测试用例完成后运行,而不是在ConnectDB()
函数返回后立即运行。
这是一种更可靠的方法,以确保我们的数据库连接在正确的时间关闭。如果我们使用defer
,数据库连接将在ConnectDB()
函数返回后立即关闭,这可能不是我们想要的。
译自:https://levelup.gitconnected.com/take-golang-testing-beyond-the-basics-960ae3705a76
评论(0)