在 Go 中轻松进行单元测试
单元测试在 Go 中变得简单
在本文中,我们将学习 Go 中的单元测试。 Go 内置了测试工具,因此,我们不需要昂贵的设置或第三方库来创建单元测试。
像细胞一样是我们身体的构建单位,同样的,单元组件构成软件。类似的,由于我们身体的功能取决于这些细胞的绝对效率和可靠性,软件的效率和可靠性取决于单元组件的效率和可靠性。
那么他们是如何去组成这些单元的呢? 它们可以是函数,结构体,方法以及最终用户可能依赖的一些所有内容。 因此,我们需要确保在不修改程序代码的情况下允许传入任何数据。并保证程序的正确执行。
问题来了,我们如何测试这些单元组件的完整性呢? 通过创建单元测试。 单元测试是通过所有可能的方式测试单元组件并将结果与预期输出进行比较的程序。
那么我们应该测试什么呢?如果我们有一个模块或一个包,我们应该测试这个模块或包中可访问的任何输出(因为它们将会被终端用户使用)。如果我们有一个可执行的软件包,那么无论这个包内有多少可访问单元,我们都应该对其进行测试。
让我们看一下 Go 语言中的单元测试
import "testing"
func TestAbc(t *testing.T) {
t.Error() // 表示测试失败
}
这是 Go 中单元测试的基本结构。内置的 testing 包是由 Go 的标准库提供的。单元测试是一个函数,它接收类型为 *testing
.T 的参数并在其上调用 Error(或稍后我们看到的任何其他错误方法)。这个函数必须以 Test
关键字开头,后面的名称必须以大写字母开头(例如,TestMultiply
而不是 Testmultiply
)。
我们首先在 $GOPATH
中执行老式的可执行包。我们创建了一个问候包,我们将使用它打印一些问候消息。
在 greeting
包中,我们有两个文件。hello.go
提供一个 hello
函数,该函数接受 user 字符串并返回内容为 Hello 的问候消息。在作为程序执行入口的 main.go
文件中,我们使用 hello
函数,并将结果输出到控制台。我们使用 aurora
包在控制台打印彩色文字。
在这里,hello 函数成为我们程序的一个单元组件
要执行该程序,我们需要使用 go run*.go
,因为 hello
函数是从hello.go
文件提供的。因此,我们需要一次运行所有文件。
到目前为止,我们有信心hello
函数在任何情况下都可以正常工作。那么 如果有人将空字符串传递给hello
函数,该怎么办? 我们需要通过返回默认消息来处理这种情况。
当用户提供一个空的用户参数时,我们将以 Dude 作为默认用户返回问候消息。到目前为止,还不错,但是 hello 函数本来可能会更复杂,并且测试主函数内部单元组件的所有功能不是一个好习惯。现在该为 hello 函数创建一个单元测试,以使用所有可能的参数对其进行测试。
在 Go 中,您将单元测试保存在单独的文件中,文件名以 _test.go
结尾。 Go 提供了开箱即用的 go test
命令,该命令执行这些文件并运行测试。接下来让我们为 hello
函数创建一个测试函数。
我们已经创建了 hello_test.go
文件来测试 hello.go
中的 hello
函数。通常情况下,在单个测试文件中可以有任意多个测试函数。测试用例(函数)的集合称为测试套件。
在 TestHello
测试函数内部,我们使用一些参数调用了 hello
函数,并检查结果是否符合预期。如果执行结果无效,则应调用 t.Error
或t.Fail
或 t.Errorf
表示测试中存在错误。
若要执行当前包中的所有测试,请使用命令 GO TEST
。Go 将运行除 main 函数之外的所有测试文件以及其他文件。如果有 testing.T
没有遇到任何错误,则表示所有测试都通过。同时 GO 将输出测试结果,其中包含包名称和执行时间(以秒为单位)。
$ greeting go test
PASS
ok greeting 0.006s
您可以使用 -v
命令参数(Flag)输出打印有关测试函数的其他信息。
我们可以使用 t.Log
或 t.Logf
方法以详细模式记录其他信息,如下所示。
目前为止,我们的测试都通过了,但是如果测试失败了怎么办?让我们修改 hello
函数,并在输入参数不为空时返回问候语且不带标点。这确实看起来像是我们在编写 hello
函数时可能犯的错误。让我们创建两个测试,一个用于空参数,另一个用于有效参数。
尽管这不是创建两个测试函数来测试相同功能的好方法,但我们在这里只是举个例子。
如果您需要为测试输出着色,例如,通过测试的绿色和红色,你可以使用 gotest (使用命令 go get -u github.com/rakyll/gotest
安装).
如果您有大量的测试文件包含测试函数,但要有选择地运行几个,您可以使用 -run
标志将测试函数与其名称进行匹配。
您可以指定要运行的选定测试文件,但在这种情况下,Go 不会加载文件中的依赖。因此,您还需要指定依赖文件。
如果主包中有测试用例(
_test.go
文件),则不能简单地执行go run *.go
来运行项目,因为此命令会把测试文件一起运行。这么做的话,不可避免的,你会得到类似报错:go run: cannot run *_test.go files (hello_test.go)
。您可以将所有测试文件放在不同的程序包中,也可以使用go build
命令,然后运行二进制文件。
测试覆盖率
测试覆盖率是测试套件覆盖的代码的百分比。它是在运行测试套件时执行了多少行代码的度量(与代码中的总行相比)。
在上面的示例中,我们有 testHello
测试函数,用于测试 hello
函数是否为非空参数。我已删除 main.go
使我们的包不可执行(也更改了Hello函数大小写)。
在 go test
命令的后面加标识 -cover
即可查看代码覆盖率。在这里,我们的测试通过了,但代码覆盖率只有66.7%。这意味着只有66.7%的代码由测试执行。我们需要看到我们在测试中遗漏了哪些内容。
Go 提供一个附加的 -coverprofile
标志,用于输出文件中有关覆盖率信息的信息。使用此标志时我们不再需要使用 -cover
标志。
在上面的示例中,我们将覆盖结果提取到 cover.txt
文件中。在这里,文件的扩展名 .txt
并不重要,它可以是任意名称。这个覆盖率概要文件可以用来查看代码的哪些部分没有被测试覆盖。GO 提供了 Cover 工具来分析测试的覆盖信息。我们使用此工具接受我们的覆盖率配置文件,并以交互性非常强的格式输出一个HTML文件,其中包含该测试的具体信息。
我们使用 go tool cover
命令调用 Cover工具。我们指示 cover tool
使用接受覆盖率配置文件的 -html
标志输出 HTML 代码。-o
标志用于将此信息输出到文件。在上面的示例中,我们创建了一个包含覆盖率信息的 cover.html
文件。
如果您在浏览器内打开 cover.html
文件,您可以清楚地看到测试未覆盖的代码部分。在上面的例子中,红色的代码不在测试范围内。这证明我们没有编写 Hello
函数接收空字符串作为参数的测试用例。
Go Module 中的测试
到目前为止,我们所做的是为位于 $GOPATH
内的包创建测试用例。在 Go1.11之后,我们有受欢迎的 Go Module。在上一个教程中,我们创建了一个处理数字的模块。让我们继续为它编写测试用例。
在我们的 calc
包中,我们已经创建了一个名为 mathtest.go
的测试文件,其中包含测试函数 TestMathAdd
来验证 add
函数。到目前为止,我们对此很熟悉,但是当我们想要对 GO 模块中的包运行测试时,您可以进入包目录并运行命令 go test
,或者使用上面所示的包目录的相对路径。
如果您的模块包含包源代码,则可以使用模块中的
go test
命令运行测试套件。这意味着如果最终用户将您的模块作为包导入,则go test
命令有效。
更多测试数据
使用更多数据进行测试时,单元组件更可靠。到目前为止,我们已经用一个输入数据测试了我们的单元组件,但在现实中,我们应该用足够可靠的数据来测试它们。
最好的方法是创建一个输入数据和预期结果的数组,并对该数组的每个元素(Data) 运行测试 (使用 for 循环)。保存不同类型数据的最佳类型是 struct
。
在上面的示例中,我们定义了 InputDataItem
结构类型,它保存要添加函数(inputs字段)的输入数字、预期结果(result字段)以及如果Add函数返回错误(hasError字段)。
在 TestMathAdd 函数中,我们创建了一个 InputDataItem 数组 dataItems。使用for循环,我们迭代数据项并测试Add函数是否返回预期输出。
接下来是比较复杂的情况。由于我们的 calc
包是nummanip模块的一部分,一个模块可以有多个包。我们怎么能同时测试所有的包裹呢?
让我们在 nummanip
模块中创建另一个转换包。
在上面的示例中,我们在包含 SquareSlice
函数的转换包中创建了 square.go
文件。该函数接受整数切片作为参数,并返回具有这些数字平方的切片。在square_test.go
文件中,我们创建了 TestTransformSquare
,用于测试ExpectedResult
切片和结果切片的相等性。我们使用了反射内置包装,以使用DeepEqual
函数检查此质量。最后我们使用 go test ./tranform
命令测试 tranform
包并通过测试。
到目前为止,我们已经分别测试了每个程序包。要测试模块中的所有软件包,可以使用 go test ./...
命令。
go test ./...
命令遍历每个包并运行测试文件。您可以像往常一样使用所有命令行标志,如 -v
或 -cover
。正如您在上面的结果中看到的,我们获得了代码的100%覆盖率,并且所有测试都通过了。但是有一个疑问:测试结果消息中 0.00s
不是测试的执行时间么,为什么会是 0?
有两种运行测试的方法,第一种是本地目录模式,在该模式下,我们使用 go test
运行测试,这是我们的问候语包位于 $GOPATH
时使用的方法。第二种方法是在包列表模式下运行测试。当我们列出要测试的软件包时,例如,
go test .
测试当前目录中的软件包go test
当软件包属于$GOPATH
时,不需要从 Go Module 内部执行此命令,就进行测试。go test ./tranform
来测试./tranform
目录中的包go test ./...
以测试当前目录中的所有软件包。
在包列表模式下,GO 缓存成功的测试结果,以避免重复运行相同的测试。每当 GO 在包上运行测试时,GO 都会创建一个测试二进制文件并运行它。您可以使用 go test -c
命令输出此二进制文件。这将输出 .test
文件,但不会运行它。此外,使用 -o
标志可以重命名此文件。
在我的发现中,即使测试函数的源代码有变化,如果测试二进制文件没有变化,GO 也会重用缓存中的结果。例如,重命名变量不会更改二进制文件。
没有有效的理由覆盖缓存,因为它可以节省宝贵的时间。
如果您想要查看执行时间,可以使用 -count=1
,这将只运行一次测试并忽略缓存。您还可以使用 go clean -testcache {path-to-package(s)}
。如果要全局禁用缓存,可以将 GOCACHE
环境变量设置为 off
。
区分
当我们在包中编写测试用例时,这些测试文件将向包的导出和非导出成员公开(因为它们位于源文件的同一目录中)。因此,可能会产生混淆。
为了避免这种情况,我们可以更改测试文件中的包名,并以 _test
作为前缀。这样,我们的测试文件属于不同的包(但它们仍然在我们正在测试的包的同一目录中)。因为现在测试文件属于不同的包,所以我们需要导入我们正在测试的包,并使用 .
(点)运算符访问单元组件。
在上面的示例中,我们将测试文件的包名改为 transform_test
,并从该包导入中访问 SquareSlice
函数。由于 SquareSlice
被导出(因为是大写的),它能正常工作,但是如果我们错过了大写并写了 squareSlice
的内容,则不会导出它,并且我们的测试将失败。
测试数据
假设您有一个可以操作 CSV 或 Excel 电子表格文件的软件包,并且想要为其编写测试用例,那么用以测试的 CSV 文件将存储在哪里呢?您不能将其存储在包装之外,因为这样一来,您就需要将它们单独发送给想要进行测试人,这不合理。
Go 建议在您的包内创建一个 testdata
目录。使用标准 go
命令运行或构建软件包时忽略此目录。在测试文件中,您可以使用内置的 OS 程序包访问此目录,下面演示从 testdata
访问文件的示例:
// some_test.go
file, err := os.Open("./testdata/file.go")
if err != nil {
log.Fatal(err)
}
使用断言
如果您熟悉node.js等其他语言中的测试,那么您可能使用过 chai 或内置包 assert. Go不提供任何内置的断言包。
在官方FAQ 文档:
Go 不提供断言。不可否认,它们有很多便利,但我们的经验是,程序员将它们用作拐杖,以避免考虑正确的错误处理和报告。
简而言之,Go希望开发人员编写逻辑来测试单元组件的结果,我绝对同意。但是,如果您想使用断言库来处理您绝对自信的常见情况,您可以使用 testify 包。使用此包编写测试非常简单且有趣。
在测试中,存在 Mock, Stubs, 和 Spies。 您可以在 这里 阅读它们。 由于篇幅原因,我们将在稍后的单独文章中介绍它们。
在 Go 中进行测试很容易,但是存在很多小技巧。如果您需要了解有关Go中测试内部的更多信息,请遵循此 文档
在接下来的教程中,我们将尝试介绍 Go 中的基准测试。由于基准测试的语法与我们上面看到的功能测试相似,因此您可以从 官方Go文档 中了解它们。
本文中的所有译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。
推荐文章: