本文旨在探索关键的 Go 测试元素,如并行测试、子测试、拆卸函数和测试辅助函数,以提高你的测试技能。测试在软件开发过程中扮演着至关重要的角色,在 Go 中进行测试非常简单。通常,你可以按照以下步骤进行:
创建以 _test.go 结尾的文件。创建一个符合 TestXxx(t *testing.T) 签名的函数。使用 go test 命令执行测试(或在你的 IDE 中点击按钮)。然而,你是否考虑过可以做得更多呢?在本文中,我们将深入探讨在 Go 中编写测试的附加技术,超越基本步骤。这些方法旨在使你的测试不仅功能齐全,而且易于维护和高效。
1. 处理失败标记首先,我们来讨论如何在 Go 中标记测试失败。你可以选择使用以下方法之一:
t.Fail(): 该方法仅将测试标记为失败,但不会停止测试的继续运行。t.FailNow(): 该方法将测试标记为失败,并立即使用 runtime.Goexit() 停止任何进一步的执行。t.Errorf(): 这是一个结合了 t.Logf() 记录错误消息和 t.Fail() 标记测试失败的两合一方法。t.Fatalf(): 这是另一个结合了 t.Logf() 记录错误消息和 t.FailNow() 立即停止测试的组合方法。如果你在一个函数中有多个独立的测试用例,并且希望即使其中一个用例失败也继续运行其他用例,那么 t.Errorf() 是合适的选择。例如:
然而,如果你的测试用例彼此依赖,并且如果一个用例失败就需要停止所有测试,那么你可能需要考虑使用 t.Fatalf()。例如:
2. 表驱动测试让我们来看一个基本的 Add 函数的单元测试,它接受一个整数列表作为输入。
在我们的 TestAdd 函数中,目前我们正在测试两个用例:
现在,想象一下,如果你想要测试不止这两个场景,比如10个场景呢?继续使用这种方式会导致冗余且难以维护的代码。
更高效的替代方法是表驱动测试,它既更易于阅读,又更易于更新:
通过使用表驱动方法,添加新的测试场景变得非常简单。只需更新 testCases 切片,特别是匿名结构体,就可以避免重复的代码。这样可以保持测试代码整洁,并且更容易查看所有的测试场景。
然而,目前的方法存在一个小问题,即在使用 go test -v 命令进行详细输出时,输出如下所示:
这并没有提供太多信息,只是说明测试通过了,并没有指示测试用例的数量,也没有指出具体哪个测试用例可能失败。
举个例子,假设有一个测试用例没有通过:
当测试用例失败时,输出并不那么有帮助,我们无法确定哪个用例出错了,也无法知道其输入是什么。
这就是子测试的价值所在,使用子测试可以提高测试报告的质量,在出现问题时提供更多信息和实用性。
3. 子测试:运行多个测试用例为了充分利用Go的测试包中的子测试功能,我们首先介绍一个新函数:t.Run(name string, f func(t *testing.T)) (isSuccess bool)。
函数t.Run()使用提供的名称生成一个子测试,并将函数f作为一个独立的goroutine运行。尽管每个子测试在它们自己的goroutine中执行,但它们是按顺序执行的(稍后我们将探讨并行执行)。
下面是如何修改现有的测试以包含子测试:
现在,我们为每个测试用例创建了一个子测试,并在其中运行相应的测试逻辑。这样,每个测试用例都将以其指定的名称进行报告,并且我们将获得更详细的输出。
让我们运行测试看看结果:
现在,我们可以看到每个测试用例的名称,并且测试结果更加详细和易于理解。
4. 在 Go 1.22 中修复 For 循环为了并行运行子测试,可以使用t.Parallel()来启用并行模式。
这对于拥有大量独立测试用例的情况特别有用。通过并行运行它们,可以加快整个测试过程的速度。
看看如何实现:
“好的,等等。为什么循环中有一个tc := tc?”
好吧,这是为了处理与闭包相关的常见问题。
这一行额外的代码确保每次循环迭代都有自己独立的tc副本,避免与原始的循环变量发生冲突。
困惑吗?下面是一个例子来澄清一切:
现在,你期望它输出什么?你可能会认为是“0、1、2”,对吗?
错了。
实际上,你会看到“3 3 3”,因为所有的goroutine都引用了循环结束后的同一个i的最终值,即3。为了解决这个问题,在循环开始后立即添加i := i`。
好消息是,LoopVar问题将在Go 1.22中得到解决。你可以在这篇文章中阅读更多相关内容。
5. 其他单元测试概念我们已经介绍了基础知识,但是在 Go 中进行单元测试还提供了更多功能。如果你有高级需求,你会发现还有其他类似于不同单元测试框架中提供的工具。
Helper首先,让我们讨论一下 t.Helper() 函数。根据官方描述:“Helper 标记调用函数为测试辅助函数。在打印文件和行信息时,该函数将被跳过。”
起初,我并不明白这个函数的作用。为什么我要隐藏函数细节?但是当我开始创建自己的测试工具(如断言)时,t.Helper() 的作用变得清晰起来。
举个例子,假设你正在使用一个名为 AssertNil 的辅助函数来确保另一个函数返回一个 'nil' 错误。代码如下所示:
当你像之前一样运行 go test -v 命令时,你会看到以下输出:
“那有什么大不了的?看起来报告工作得很好。”
好吧,这里有个问题。
报告过于精确,但并没有那么有用。你会注意到,错误发生在 write_test.go:10,这是 AssertNil 函数的位置。但是你真正想要的是错误发生在 TestF 函数中。
这就是 t.Helper() 的用途。当你在 AssertNil 函数中添加 t.Helper() 后,输出将更有意义:
现在,错误发生在 write_test.go:19,这是 TestF 函数中调用 AssertNil 的位置。
这个小技巧对于更复杂的测试工具函数非常有用,因为它们可能包含多个辅助函数调用和内部逻辑。
Cleanup (teardown)Go 1.14 中的 t.Cleanup() 函数一开始可能会让你困惑,它允许你定义一个在测试结束后运行的清理函数。
“延迟执行(defer)不也能做同样的事情吗?有什么大不了的?”
让我们仔细看一下。
现在,相较于使用延迟执行(defer)来清理函数,t.Cleanup() 的优势在于它可以更好地控制清理的时机。清理函数将在测试函数返回之前执行,而不仅仅是在函数退出时执行。
“有何用途呢?
为了明确这一点,让我们看一个涉及名为ConnectDB(t *testing.T)的函数的示例。该函数建立一个数据库连接,并返回该连接:
现在,让我们运行这个测试,观察t.Cleanup()在实际情况下的行为:
我们将 t *testing.T 传递给 ConnectDB 函数,并设置了一个清理函数,而不是使用 defer conn.Close()。
只有在整个测试完成后,才会显示 "关闭数据库" 的信息,而不是在 ConnectDB() 完成部分后。
这确保了我们在正确的时间关闭数据库连接,而不是过早关闭。如果使用 defer,则会在 ConnectDB() 退出时关闭连接,这可能不是你想要的结果。
更多如果你想了解更多关于 Golang 的知识,请关注我的《Golang实用技巧》专栏,它将为你揭秘生产环境最佳实践,带你探索高并发编程的实用教程。从分享实用的Golang小技巧到深入剖析实际应用场景,让你成为真正的Golang大师。无论你是初学者还是经验丰富的开发者,这里都有你所需要的灵感和知识。让我们一同探索Golang的无限可能!