软件测试中有一个金字塔模型,该模型将测试分为不同类别。如下图所示,单元测试位于金字塔最下面一层。通常,沿着金字塔越往上走,测试就越复杂,运行速度越慢,并且越难以保证它们的确定性,因此在实际开发中,团队应该有更多的单元测试。此外,单元测试还有编写成本低、执行速度快和确定性高等优点。
在进行测试前,首先要明确的是运行哪种测试。根据项目所处在生命周期中的阶段,我们可能希望仅运行单元测试或运行项目中的所有测试。如果不对测试的内容进行分类可能会费时费力,并且会失去测试范围的准确性。本文将深入讨论Go语言中对测试进行分类的三种主要方法。
对测试进行分类的最常用方法是使用编译标签(build tags). build tags是Go源文件开头的特殊注释,后面跟一个空行,像下面这样。
//go:build foo
package bar
上面的bar.go文件包含foo标签。注意,在一个包中,可以有多个文件它们有不同的标签.
「NOTE: 在Go1.17中,原来的语法 // +build foo 被//go:build foo取代。在Go1.18版中,可以通过gofmt将旧语法格式迁移到新格式。」
build tags主要有两种使用场景。第一个场景是作为构建/编译应用程序的条件选项,例如,如果我们希望仅在启用cgo时才包含源文件(cgo是一种让Go调用c代码的方法),可以在源文件中添加 //go:build cgo 标签。第二个场景是如果我们要将测试内容归类为集成测试,可以添加特定的编译标记,例如像下面这样在文件中添加integration标签。
//go:build integration
package db
import (
"testing"
)
func TestInsert(t *testing.T) {
// ...
}
上面的文件通过添加集成标签来区分该文件包含集成测试。使用编译标签(build tags)的好处是可以选择执行哪种测试。例如,假设有一个包中包含两个测试文件:
如果我们在这个包中执行go test命令(不带标签选项),将只会运行没有tags的测试文件,即这里只会运行contract_test.go。
go test -v .
=== RUN TestContract
--- PASS: TestContract (0.00s)
PASS
如果在执行go test时提供integration标签,这时运行将会包含db_test.go, 即contract_test.go和db_test.go文件都会运行。
go test --tags=integration -v .
=== RUN TestContract
--- PASS: TestContract (0.00s)
=== RUN TestInsert
--- PASS: TestInsert (0.00s)
PASS
通过上面的测试验证可以看到,如果在执行go test提供标签,将会运行含有匹配标签和不带标签的测试文件。那现在,如果我们只想运行含有integration标签的文件怎么办?一种可行的方法是在测试文件中添加否定标签,例如,这里在contract_test.go文件中添加!integration标签。使用!integration标签意味着在执行go test时未添加--tags=integration时才会包含此测试文件。
//go:build !integration
package db
import (
"testing"
)
func TestContract(t *testing.T) {
// ...
}
此时,在执行go test时:
提供integration标签时运行结果如下:
go test --tags=integration -v .
=== RUN TestInsert
--- PASS: TestInsert (0.00s)
PASS
不提供标签时运行结果如下:
go test -v .
=== RUN TestContract
--- PASS: TestContract (0.00s)
PASS
前一小节讲述了可以通过tags区分测试文件,选择特定的测试文件运行。本小节讲述通过环境变量的方法可以在测试函数级别进行分类测试。正如Go社区成员Peter Bourgon所说,通过build tags方法进行分类测试有一个大的缺点:在测试时缺少输出已忽略运行某些测试文件信息。在上小节的例子中,当我们执行go test不带标签选项时,只输出了已执行的测试函数(contract_test.go文件中的测试函数)信息,像db_test.go没有运行,但没有给任何提示。
go test -v .
=== RUN TestUnit
--- PASS: TestUnit (0.01s)
PASS
ok db 0.319s
如果我们对标签的处理方式不够小心,可能会忘记现有的测试。出于这个原因,一些项目倾向使用环境变量来进行分类测试。例如,我们可以通过检查特定环境变量并跳过对应的测试来实现TestInsert集成测试。
func TestInsert(t *testing.T) {
if os.Getenv("INTEGRATION") != "true" {
t.Skip("skipping integration test")
}
// ...
}
如果环境变量INTEGRATION没有设置为true,则会跳过测试并输出一条日志。
go test -v .
=== RUN TestInsert
db_integration_test.go:12: skipping integration test
--- SKIP: TestInsert (0.00s)
=== RUN TestUnit
--- PASS: TestUnit (0.00s)
PASS
ok db 0.319s
使用环境变量方法的一个优点是明确跳过的测试及其原因,虽然这种方法可能不如build tags使用的广泛,但还是值得了解,因为它具有标签分类没有的一个优点。
还可以根据执行的速度对测试进行分类,实际中, 我们可能不得不将短期与长期运行的测试区分开。举一个例子,现在我们有一组单元测试,但有一个执行的速度很慢。因此,我们希望以特定的方式对其进行分类,这样我们就不必每次都运行它(例如在保存文件之后进行的操作逻辑)。短模式(short mode)提供了这种分类方法。
func TestLongRunning(t *testing.T) {
if testing.Short() {
t.Skip("skipping long-running test")
}
// ...
}
使用testing.Short,可以判断在运行测试时是否启用了短模式。然后使用t.Skip跳过测试。如果要使用短模式运行测试,在执行go test命令时携带 -short参数。
go test -short -v .
=== RUN TestLongRunning
foo_test.go:9: skipping long-running test
--- SKIP: TestLongRunning (0.00s)
PASS
ok foo 0.174s
通过上面的测试输出日志可以看到,TestLongRunning被排除并从执行的测试中显示跳过。注意,短模式适用于单个测试项(像这里的TestLongRunning),它不是针对文件级别的。
总结,对测试进行分类是进行成功测试的最佳实践。本文讲述了三种对测试进行分类的方法:
在测试时,不局限于上面的一种方法,我们也可以结合多种方法。例如,如果我们的程序包含长时间运行的单元测试,则可以使用编译标签结合短模式,或者采用环境变量和短模式对其进行分类测试。