本文来自正在规划的Go语言&云原生自我提升系列,欢迎关注后续文章。
2000年以来,自动化测试的广泛应用可能比任何其他软件工程技术都更能提高代码质量。Go是一种专注于提高软件质量的语言和生态系统,很自然的在其标准库中包含了测试支持。Go中测试代码非常容易,没有理由不添加测试。本章中,读者将了解如何测试Go代码,将测试分组为单元测试和集成测试,检查测试代码覆盖率,编写基准测试,并学习如何使用Go竞态检查器检测代码的并发问题。同时,还会讨论如何编写可测试的代码,以及为什么它会提高我们的代码质量。
测试的基础知识
Go的测试支持由两部分组成:库和工具。标准库中的testing
包提供了编写测试所需的类型和函数,而Go包中的go test
工具可运行测试并生成报告。与许多其他语言不同,Go的测试放在与生产代码相同的目录和相同的包中。由于测试位于同一包中,它们能够访问和测试未导出的函数和变量。读者很快就会学到如何编写仅测试公开API的测试。
注:本章的完整代码示例请见第15章的GitHub代码库。
下面编写一个简单的函数,然后编写测试来确保该函数正常运行。在sample_code/adder目录中的文件adder.go中添加如下代码:
1 2 3 |
func addNumbers(x, y int) int { return x + x } |
相应的测试为adder_test.go:
1 2 3 4 5 6 |
func Test_addNumbers(t *testing.T) { result := addNumbers(2,3) if result != 5 { t.Error("incorrect result: expected 5, got", result) } } |
测试文件都以_test.go结尾。如要为foo.go编写测试,可将测试放在名为foo_test.go的文件中。
测试函数以Test
开头,并接收一个类型为*testing.T
的参数。按照惯例,该参数被命名为t
。测试函数不返回任何值。测试的名称(不含开头的“Test”)用于记录正在进行测试的内容,因此请选择能说明您正在测试内容的名称。在为单独的函数编写单元测试时,惯例是将单元测试命名为Test
后接函数的名称。在测试未导出函数时,有些人会在Test
和函数名称之间使用下划线。
此外,请注意使用标准的Go代码来调用正在进行测试的代码,并验证响应是否与预期一致。当结果不正确时,可以使用t.Error
方法报告错误,使用方式类似于fmt.Print
函数。读者很快会学到其他报告错误的方法。
刚刚了解了Go测试支持库的部分。下面来看看工具部分。就像go build
用于构建二进制文件、go run
用于运行文件一样,命令go test
用于运行当前目录中的测试函数:
1 2 3 4 5 6 |
$ go test --- FAIL: Test_addNumbers (0.00s) adder_test.go:8: incorrect result: expected 5, got 4 FAIL exit status 1 FAIL test_examples/adder 0.006s |
貌似我们发现了代码中的bug。仔细观察addNumbers
,发现我们使用x
加x
,而不是x
加y
。下面修改代码重新运行测试看bug是否已修复:
1 2 3 |
$ go test PASS ok test_examples/adder 0.006s |
go test
命令允许我们指定要测试的包。使用./...
作为包名称表示要运行当前目录以及其所有子目录中的测试。添加-v
标记获取详细的测试输出。
报告测试失败
*testing.T
有几个报告测试失败的方法。我们已经用过Error
,它将一组逗号分隔的值构建成一个失败描述字符串。
如果读者更乐意使用类似Printf
的格式化字符串来生成消息,请改用Errorf
方法:
1 |
t.Errorf("incorrect result: expected %d, got %d", 5, result) |
虽然Error
和Errorf
标记测试为失败,但测试函数会继续运行。如果觉得测试函数应该在发现失败后立即停止处理,使用Fatal
和Fatalf
方法。Fatal
方法类似于Error
,而Fatalf
方法类似Errorf
。不同之处在于,在生成测试失败消息后,测试函数会立即退出。注意,它不会退出所有测试;在当前测试函数退出后,剩余的测试函数都将继续执行。
何时该使用Fatal
/Fatalf
,何时该使用Error
/Errorf
呢?如果测试中的某个检查失败表示同一测试函数中的后续检查也将失败或导致测试panic,那么请使用Fatal
或Fatalf
。如果测试多个独立的项目(例如验证结构体中的字段),那么请使用Error
或Errorf
,这样可同时报告多个问题。也就可以更轻松地修复多个问题,而无需一次又一次地反复运行测试。
设置和清理
有时可能希望在运行测试之前设置一些通用状态,并在测试完成时将其删除。使用TestMain
函数来管理该状态并运行测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
var testTime time.Time func TestMain(m *testing.M) { fmt.Println("Set up stuff for tests here") testTime = time.Now() exitVal := m.Run() fmt.Println("Clean up stuff after tests here") os.Exit(exitVal) } func TestFirst(t *testing.T) { fmt.Println("TestFirst uses stuff set up in TestMain", testTime) } func TestSecond(t *testing.T) { fmt.Println("TestSecond also uses stuff set up in TestMain", testTime) } |
TestFirst
和TestSecond
都引用了包级变量testTime
。正确运行测试需要对其进行初始化。我们声明了一个名为TestMain
的函数,其参数类型为*testing.M
。如果包中有名为TestMain
的函数,go test
会去调用它,而不是其它测试函数。TestMain
函数的责任是设置包中的测试正常运行所需的所有状态。一旦配置了状态,TestMain
函数会在*testing.M
上调用Run
方法。这会运行包中的测试函数。Run
方法返回退出码;0
表示所有测试全部通过。最后,TestMain
函数必须使用Run
返回的退出码调用os.Exit
。
运行go test
输出如下:
1 2 3 4 5 6 7 8 9 |
$ go test Set up stuff for tests here TestFirst uses stuff set up in TestMain 2020-09-01 21:42:36.231508 -0400 EDT m=+0.000244286 TestSecond also uses stuff set up in TestMain 2020-09-01 21:42:36.231508 -0400 EDT m=+0.000244286 PASS Clean up stuff after tests here ok test_examples/testmain 0.006s |
注:请注意,
TestMain
只会调用一次,不会在每个单独的测试之前和之后都调用。此外,请注意每个包只能有一个TestMain
。
一般在两种常见情况下使用TestMain
:
- 需要在外部存储(如数据库)中设置数据时。
- 测试的代码依赖于需初始化的包级变量时。
前面已提到(还会再次提到!),应避免在程序中使用包级变量。那会让我们难以理解数据如何在程序中流动。如果出于这个原因使用TestMain
,请考虑重构代码。
*testing.T
上的Cleanup
方法用于清理为单个测试创建的临时资源。该方法有一个参数,即没有输入参数或返回值的函数。该函数在测试完成时运行。对于简单的测试,可使用defer
语句来实现相同的结果,但是当测试依赖于帮助函数来设置示例数据时,如例12-1,Cleanup
非常有用。可以多次调用Cleanup
,和defer
一样,函数按照最后添加最先调用的顺序调用。
例12-1 使用t.Cleanup
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
// createFile is a helper function called from multiple tests func createFile(t *testing.T) (_ string, err error) { f, err := os.Create("tempFile") if err != nil { return "", err } defer func() { err = errors.Join(err, f.Close()) }() // write some data to f t.Cleanup(func() { os.Remove(f.Name()) }) return f.Name(), nil } func TestFileProcessing(t *testing.T) { fName, err := createFile(t) if err != nil { t.Fatal(err) } // do testing, don't worry about cleanup } |
如果测试中使用了临时文件,您可以利用*testing.T
上的TempDir
方法,无需编写清理代码。每次调用此方法时,都会新建一个临时目录,并返回目录的完整路径。它还会在测试完成时使用Cleanup
注册一个处理程序,删除目录及其内容。可以使用它重写上面的示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
// createFile is a helper function called from multiple tests func createFile(tempDir string) (_ string, err error) { f, err := os.CreateTemp(tempDir, "tempFile") if err != nil { return "", err } defer func() { err = errors.Join(err, f.Close()) }() // write some data to f return f.Name(), nil } func TestFileProcessing(t *testing.T) { tempDir := t.TempDir() fName, err := createFile(tempDir) if err != nil { t.Fatal(err) } // do testing, don't worry about cleanup } |
使用环境变量进行测试
使用环境变量来配置应用程序是一种常见(也是非常好的)做法。为有助测试环境变量解析代码,Go在testing.T
上提供了一个帮助方法。调用t.Setenv()
可为测试注册环境变量的值。在背后,它会在测试退出时调用Cleanup
,将环境变量恢复到其先前的状态。
1 2 3 4 5 6 7 8 9 10 |
// assume ProcessEnvVars is a function that processes envrionment variables // and returns a struct with an OutputFormat field func TestEnvVarProcess(t *testing.T) { t.Setenv("OUTPUT_FORMAT", "JSON") cfg := ProcessEnvVars() if cfg.OutputFormat != "JSON" { t.Error("OutputFormat not set correctly") } // value of OUTPUT_FORMAT is reset when the test function exits } |
注:虽然使用环境变量来配置应用程序是好做法,但也应确保大部分代码完全不知道它们的存在。在程序开始运行之前,确保将环境变量的值复制到配置结构体中,可以在
main
函数或其后的位置实现。这样做可以更易于重用和测试代码,因为代码的配置方式已经从代码的实际功能抽象出来了。相比自己编写这些代码,强烈建议考虑使用第三方配置库,比如Viper或envconfig。此外,可以考虑使用GoDotEnv将环境变量存储在
.env
文件中,以供开发或持续集成机器使用。
存储样本测试数据
在go test
遍历源代码树时,它将当前包目录用作工作目录。如果想要使用样本数据来测试包中的函数,请创建一个名为 testdata 的子目录来存储文件。Go 保留此目录名称作为保存测试文件的位置。在从 testdata 读取时,保持使用相对文件引用。由于go test
会将当前工作目录更改为当前包,每个包会通过相对文件路径访问自己的 testdata。
小贴士:
text
包演示了如何使用 testdata。
缓存测试结果
就像在模块、包和导入中学到的那样,如果编译的包没有更改,Go 会将其缓存起来,在跨包运行测试时,如果测试已经通过且其代码没有更改,Go 也会缓存测试结果。如果修改了包中的任何文件或 testdata 目录中的文件,测试将被重新编译并重新运行。如果传递标记 -count=1
给 go test
,还可以强制测试始终运行。
测试公开API
我们编写的测试与生产代码位于同一个包中。这样能够测试导出和未导出的函数。
如果只想测试包的公开API,Go 有一种约定来进行指定。依然将测试源代码保存在与生产源代码相同的目录中,但使用 packagename_test
作为包名。重新执行我们最初的测试案例,这次使用一个已导出的函数。代码位于第15章的GitHub代码库中的 sample_code/pubadder 目录中。如果在 pubadder
包中有以下函数:
1 2 3 |
func AddNumbers(x, y int) int { return x + y } |
那么可以使用pubadder
包中 adder_public_test.go文件的如下代码测试公开API:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
package pubadder_test import ( "github.com/learning-go-book-2e/ch15/sample_code/pubadder" "testing" ) func TestAddNumbers(t *testing.T) { result := pubadder.AddNumbers(2, 3) if result != 5 { t.Error("incorrect result: expected 5, got", result) } } |
请注意,我们测试文件的包名为 pubadder_test
。尽管这些文件位于同一个目录中,我们仍需要导入 github.com/learning-go-book-2e/ch15/sample_code/pubadder
。为遵循测试命名的约定,测试函数的名称与 AddNumbers
函数的名称相匹配。另外注意,我们使用了 pubadder.AddNumbers
,因为是在不同的包中调用了一个已导出的函数。
小贴士:如果手动输入此代码,需要创建一个带有模块声明文件
go.mod
的模块:
1 module github.com/learning-go-book-2e/ch15并将源代码放在模块的 sample_code/pubadder 目录中。
正如可以从包内部调用已导出的函数一样,也可以从源代码同一个包中的测试中测试公开API。在包名中使用_test
后缀的优势是,可以将所测试的包视为“黑盒”。只能通过其导出的函数、方法、类型、常量和变量与其进行交互。还要注意,可以在同一源代码目录中交叉使用两个包名的测试源文件。
使用go-cmp
比较测试结果
编写两个复合类型实例之间的完整比较可能会很冗长。虽然我们可以使用reflect.DeepEqual
来比较结构体、字典和切片,但还有一种更好的方法。Google 发布了一个名为 go-cmp
的第三方模块,它可以做这种比较,并返回不匹配内容的详细描述。我们通过定义一个简单的结构体和一个为其赋值的工厂函数来看看是如何使用的。代码位于第15章的GitHub代码库的 sample_code/cmp 目录中:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
type Person struct { Name string Age int DateAdded time.Time } func CreatePerson(name string, age int) Person { return Person{ Name: name, Age: age, DateAdded: time.Now(), } } |
在测试文件中,需要导入github.com/google/go-cmp/cmp
,测试函数如下:
1 2 3 4 5 6 7 8 9 10 |
func TestCreatePerson(t *testing.T) { expected := Person{ Name: "Dennis", Age: 37, } result := CreatePerson("Dennis", 37) if diff := cmp.Diff(expected, result); diff != "" { t.Error(diff) } } |
cmp.Diff
函数接收期望输出和待测试函数返回的输出作为参数。它返回一个字符串,描述这两个输入之间的不匹配之处。如果输入匹配,它将返回一个空字符串。将cmp.Diff
函数的输出赋给一个名为 diff
的变量,然后检测 diff
是否为空字符串。如果不为空,就表示发生了错误。
在构建并运行测试时,会看到go-cmp
在两个结构体实例不匹配时生成的输出:
1 2 3 4 5 6 7 8 9 10 11 |
$ go test --- FAIL: TestCreatePerson (0.00s) ch13_cmp_test.go:16: ch13_cmp.Person{ Name: "Dennis", Age: 37, - DateAdded: s"0001-01-01 00:00:00 +0000 UTC", + DateAdded: s"2020-03-01 22:53:58.087229 -0500 EST m=+0.001242842", } FAIL FAIL ch13_cmp 0.006s |
以-
和 +
开头的行表示值不同的字段。这里测试失败是因为时间不匹配。这是一个问题,因为您无法控制 CreatePerson
函数所赋的时间。需要忽略 DateAdded
字段。通过指定一个比较函数来实现。在测试中将该函数声明为一个局部变量:
1 2 3 |
comparer := cmp.Comparer(func(x, y Person) bool { return x.Name == y.Name && x.Age == y.Age }) |
将一个函数传递给cmp.Comparer
函数,以创建一个自定义比较器。传入的函数两个参数必须为相同类型,并返回一个布尔值。它还必须是对称的(参数的顺序无关紧要)、确定性的(对于相同的输入,始终返回相同的值)和纯粹的(不能修改其参数)。在我们的实现中,比较 Name
和 Age
字段,忽略 DateAdded
字段。
然后修改调用cmp.Diff
的代码,包含comparer
:
1 2 3 |
if diff := cmp.Diff(expected, result, comparer); diff != "" { t.Error(diff) } |
这只是对go-cmp
最有用特性的快速概览。参见其官方文档学如何控制比较内容和输出格式。
表格测试
大多数情况下,验证函数是否正确运行通常需要多个测试用例。可以编写多个测试函数来验证该函数,或者在同一个函数内编写多个测试,但你会发现大部分测试逻辑是重复的。需要设置支持数据和函数,指定输入,检查输出,并进行比较以查看它们是否符合预期。与其一遍又一遍地编写这些内容,不如利用一种称为表格测试的模式。我们来看一个示例。代码们于第15章的GitHub代码库的 sample_code/table 目录中。假设我们在 table
包中有以下函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
func DoMath(num1, num2 int, op string) (int, error) { switch op { case "+": return num1 + num2, nil case "-": return num1 - num2, nil case "*": return num1 + num2, nil case "/": if num2 == 0 { return 0, errors.New("division by zero") } return num1 / num2, nil default: return 0, fmt.Errorf("unknown operator %s", op) } } |
要测试该函数,需要检查不同的分支,尝试返回有效结果的输入,以及触发错误的输入。可以编写如下代码,但这非常繁复:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
func TestDoMath(t *testing.T) { result, err := DoMath(2, 2, "+") if result != 4 { t.Error("Should have been 4, got", result) } if err != nil { t.Error("Should have been nil error, got", err) } result2, err2 := DoMath(2, 2, "-") if result2 != 0 { t.Error("Should have been 0, got", result2) } if err2 != nil { t.Error("Should have been nil error, got", err2) } // and so on... } |
我们改用表格测试。首先,声明一个匿名结构体切片。结构体包含测试的名称、入参和返回值的字段。切片中的每条代表一个测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
data := []struct { name string num1 int num2 int op string expected int errMsg string }{ {"addition", 2, 2, "+", 4, ""}, {"subtraction", 2, 2, "-", 0, ""}, {"multiplication", 2, 2, "*", 4, ""}, {"division", 2, 2, "/", 1, ""}, {"bad_division", 2, 0, "/", 0, `division by zero`}, } |
接着,循环遍历data
中的每个测试用例,每次调用 Run
方法。这是有魔法的一行代码。我们向 Run
传递两个参数,一个子测试的名称,和一个带有类型为*testing.T
的单个参数的函数。在函数内部,我们使用data
中当前条目的字段调用 DoMath
,一遍又一遍地使用相同的逻辑。在运行这些测试时,不仅会看到它们通过了,而且当您使用-v
标记时,每个子测试都有一个名称:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
for _, d := range data { t.Run(d.name, func(t *testing.T) { result, err := DoMath(d.num1, d.num2, d.op) if result != d.expected { t.Errorf("Expected %d, got %d", d.expected, result) } var errMsg string if err != nil { errMsg = err.Error() } if errMsg != d.errMsg { t.Errorf("Expected error message `%s`, got `%s`", d.errMsg, errMsg) } }) } |
小贴士:比较错误消息很不可靠,因为可能没有对消息文本的兼容性保证。我们正在测试的函数使用
errors.New
和fmt.Errorf
创建错误,因此唯一的选择是比较消息。如果错误为自定义类型,请使用errors.Is
或errors.As
来检查是否返回了正确的错误。
有了运行大量测试的方法,下面了解一下测试代码的覆盖率。
并发运行测试
默认情况下,单元测试是顺序运行的。由于每个单元测试应与其他单元测试相互独立,进行并发测试非常理想。要使单元测试与其他测试并发运行,可在测试中的第一行调用*testing.T
上的 Parallel
方法:
1 2 3 4 |
func TestMyCode(t *testing.T) { t.Parallel() // rest of test goes here } |
并行测试与其他标记为并行的测试并发运行。
并行测试的优势在于它可以加速运行时间较长的测试套件。但也有一些缺点。如果有多个依赖于相同共享可变状态的测试,请不要将它们标记为并行,因为会得到不一致的结果。(但在所有这些警告之后,你的应用程序中没有任何共享可变状态吧?)此外,还要注意,如果将测试标记为并行并在测试函数中使用 Setenv
方法,测试会 panic。
在并行运行表格测试时要小心。当表格测试并行运行时,就像我们在 for 循环中启动了多个 goroutine 一样。在这个示例中,变量d
的引用被所有并行测试共享,因此它们都看到相同的值:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
func TestParallelTable(t *testing.T) { data := []struct { name string input int output int }{ {"a", 10, 20}, {"b", 30, 40}, {"c", 50, 60}, } for _, d := range data { t.Run(d.name, func(t *testing.T) { t.Parallel() fmt.Println(d.input, d.output) out := toTest(d.input) if out != d.output { t.Error("didn't match", out, d.output) } }) } } |
可在The Go Playground中运行这段代码或是通过第15章的GitHub代码库的 sample_code/parallel 目录获取代码。查看输出会看到对表格测试中最后的值进行了三次测试:
1 2 3 4 5 6 |
=== CONT TestParallelTable/a 50 60 === CONT TestParallelTable/c 50 60 === CONT TestParallelTable/b 50 60 |
要避免这一问题,在for循环中调用t.Run
前遮蔽d
:
1 2 3 4 5 6 7 8 9 10 11 |
for _, d := range data { d := d // THIS IS THE LINE THAT SHADOWS d! t.Run(d.name, func(t *testing.T) { t.Parallel() fmt.Println(d.input, d.output) out := toTest(d.input) if out != d.output { t.Error("didn't match", out, d.output) } }) } |
检测代码覆盖率
代码覆盖率是一个非常有用的工具,可以知道是否漏掉了某些明显的状况。但达到100%的测试覆盖率并不能保证在某些输入下代码中没有错误。首先,我们会学习如何使用go test
展示代码覆盖率,然后我们会了解仅依赖代码覆盖率的局限性。
在go test
命令中添加-cover
标记可以计算覆盖率信息,并在测试输出中添加摘要。如果再加上一个-coverprofile
的参数,可将覆盖率信息保存到一个文件中。我们再回到第15章的GitHub代码库的sample_code/table目录中,收集代码覆盖率信息:
1 |
$ go test -v -cover -coverprofile=c.out |
如果检测表格测试的代码覆盖率,测试输出会显示一行信息,代码覆盖率为87.5%。虽然这是有用的信息,但我们更希望看到漏掉了哪些测试。Go 附带的cover
工具会生成包含了这些信息的 HTML 表示:
1 |
$ go tool cover -html=c.out |
运行该命令,应该会打开浏览器并能看到如图12-1的页面:
图12-1:初始测试代码覆盖率
每个测试过的文件都会出现在左上角的组合框中。源代码有三种颜色。灰色表不可测试的代码行,绿色表已被测试覆盖的代码,红色表未经测试的代码。通过观察颜色,可以看出我们没有对default分支编写测试,即对函数传递错误的运算符时。下面将这种情况添加到测试列表中:
1 |
{"bad_op", 2, 2, "?", 0, `unknown operator ?`}, |
重新运行go test -v -cover -coverprofile=c.out
和go tool cover
-html=c.out
,可在图12-2中看到测试代码覆盖率为100%。
图12-2:最终测试代码覆盖率
代码覆盖率非常棒,但也有不足。虽然有100%的覆盖率,但代码中却有一个bug。不知读者有没有注意到?如果没有,可以添加另一个测试用例然后运行测试:
1 |
{"another_mult", 2, 3, "*", 6, ""}, |
可以看到如下错误:
1 |
table_test.go:57: Expected 6, got 5 |
在乘法用例中有一处笔误。对乘法使用了加号。(复制、粘贴代码时要格外小心!)修改代码,再次运行go test -v -cover -coverprofile=c.out
和go tool cover -html=c.out
,测试会正常通过。
警告:代码覆盖率很有必要,但并不足够。覆盖率为100%的代码仍可能存在bug。
基准测试
确定代码是快或慢非常复杂。我们不用自己计算,应使用Go测试框架内置的基准测试。下面来看第15章的GitHub代码库sample_code/bench目录下的函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
func FileLen(f string, bufsize int) (int, error) { file, err := os.Open(f) if err != nil { return 0, err } defer file.Close() count := 0 for { buf := make([]byte, bufsize) num, err := file.Read(buf) count += num if err != nil { break } } return count, nil } |
这个函数计算文件中的字数。它接收两个参数,文件名和用于读取文件的缓冲大小(稍后会讲到第二个参数的作用)。
在测试其速度前,应当测试代码运行是否正常。以下是简单的测试:
1 2 3 4 5 6 7 8 9 |
func TestFileLen(t *testing.T) { result, err := FileLen("testdata/data.txt", 1) if err != nil { t.Fatal(err) } if result != 65204 { t.Error("Expected 65204, got", result) } } |
下面来看运行该函数需要多长时间。我们的目标是找出该使用多大的缓冲区读取文件。
注:在花时间坠入优化的深渊之前,请明确程序需要进行优化。如果程序已经足够快,满足了响应要求,并且使用的内存量在接受范围之内,那么将时间花在新增功能和修复bug上会更好。业务的需求决定了何为”足够快”和”接受范围之内”。
在 Go 中,基准测试是测试文件中以单词Benchmark
开头的函数,它们接受一个类型为*testing.B
的参数。这种类型包含了*testing.T
的所有功能,以及用于基准测试的额外支持。首先看一个使用 1 字节缓冲区的基准测试:
1 2 3 4 5 6 7 8 9 10 11 |
var blackhole int func BenchmarkFileLen1(b *testing.B) { for i := 0; i < b.N; i++ { result, err := FileLen("testdata/data.txt", 1) if err != nil { b.Fatal(err) } blackhole = result } } |
blackhole
包级变量是有作用的。我们将 FileLen
的结果写入这个包级变量,以确保编译器不会自负到优化掉对 FileLen
的调用,而对基准测试产生破坏。
每个 Go 基准测试都必须有一个循环,从 0 迭代到 b.N
。测试框架会一遍又一遍地调用我们的基准测试函数,每次传递更大的 N
值,直到确保时间结果准确为止。马上会在输出中看到这一点。
我们通过向go test
传递-bench
标记来运行基准测试。该标记接收一个正则表达式来描述要运行的基准测试名称。使用-bench=.
来运行所有基准测试。第二个标记-benchmem
在基准测试输出中包含内存分配信息。所有测试在基准测试之前运行,因此只有在测试通过时才能对代码进行基准测试。
以下是运行基准测试我电脑上的输出:
1 |
BenchmarkFileLen1-12 25 47201025 ns/op 65342 B/op 65208 allocs/op |
运行含内存分配信息的基准测试输出有5列。分别如下:
- BenchmarkFileLen1-12
- 基准测试的名称,中间杠,加用于测试的GOMAXPROCS的值。
- 25
- 产生稳定输出运行测试的次数。
- 47201025 ns/op
- 该基准测试运行单次通过的时间,单位是纳秒(1秒为1,000,000,000纳秒)。
- 65342 B/op
- 基准测试单次通过所分配的字节数。
- 65208 allocs/op
- 基准测试单次通过堆上分配字节的次数。其值小于等于字节的分配数。
我们已经得到1字节缓冲的结果,下面来看使用其它大小缓冲所得到的结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
func BenchmarkFileLen(b *testing.B) { for _, v := range []int{1, 10, 100, 1000, 10000, 100000} { b.Run(fmt.Sprintf("FileLen-%d", v), func(b *testing.B) { for i := 0; i < b.N; i++ { result, err := FileLen("testdata/data.txt", v) if err != nil { b.Fatal(err) } blackhole = result } }) } } |
和使用t.Run
启动表格测试类似,我们使用b.Run
启动不同输入的基准测试。作者电脑上的结果如下:
1 2 3 4 5 6 |
BenchmarkFileLen/FileLen-1-12 25 47828842 ns/op 65342 B/op 65208 allocs/op BenchmarkFileLen/FileLen-10-12 230 5136839 ns/op 104488 B/op 6525 allocs/op BenchmarkFileLen/FileLen-100-12 2246 509619 ns/op 73384 B/op 657 allocs/op BenchmarkFileLen/FileLen-1000-12 16491 71281 ns/op 68744 B/op 70 allocs/op BenchmarkFileLen/FileLen-10000-12 42468 26600 ns/op 82056 B/op 11 allocs/op BenchmarkFileLen/FileLen-100000-12 36700 30473 ns/op 213128 B/op 5 allocs/op |
结果符合预期;随着缓冲区大小的增加,分配次数减少,代码运行速度更快,直至缓冲区大于文件的大小。当缓冲区大于文件大小时,会有额外的分配导致输出减慢。如果我们预期文件大致是这个大小,那么10,000 字节的缓冲区效果最佳。
但是有一个改动可以进一步提高性能。现在每次从文件获取下一组字节时都重新分配缓冲区。这是没必要的。如果我们在循环之前进行字节切片分配,然后重新运行基准测试,会看到提升:
1 2 3 4 5 6 |
BenchmarkFileLen/FileLen-1-12 25 46167597 ns/op 137 B/op 4 allocs/op BenchmarkFileLen/FileLen-10-12 261 4592019 ns/op 152 B/op 4 allocs/op BenchmarkFileLen/FileLen-100-12 2518 478838 ns/op 248 B/op 4 allocs/op BenchmarkFileLen/FileLen-1000-12 20059 60150 ns/op 1160 B/op 4 allocs/op BenchmarkFileLen/FileLen-10000-12 62992 19000 ns/op 10376 B/op 4 allocs/op BenchmarkFileLen/FileLen-100000-12 51928 21275 ns/op 106632 B/op 4 allocs/op |
现在分配的次数相同且较小,每个缓冲区大小仅需四次分配。有意思的是,我们现在可以作出权衡。如果内存紧张,可以使用较小的缓冲区大小,在牺牲性能的情况下节约内存。
Go代码性能调优
如果基准测试显示存在性能或内存问题,下一步是确定问题的具体原因。Go 包含了分析工具,可从正在运行的程序中收集 CPU 和内存使用数据,还有用于可视化和解释生成的数据的工具。甚至可以暴露一个 Web 服务端点,远程从运行的 Go 服务中收集分析信息。
讨论性能调优工具不在我们的范畴。线上有许多很好的资源提供相关信息。一个不错的起点是 Julia Evans 的博文使用 pprof 对 Go 程序做性能分析。
Go的桩代码(Stub)
截至目前,我们测试的函数都不依赖其他代码的。但这并不具代表性,因为大多数代码都存在依赖关系。我们学过Go提供了两种方式来抽象函数调用:定义函数类型和定义接口。这些抽象不仅有助写出模块化的生产代码,还有助于我们编写单元测试。
小贴士:在代码有抽象依赖时,编写单元测试会更容易!
来看第15章的GitHub代码库的sample_code/solver目录中示例代码。我们定义了一个名为 Processor
的类型:
1 2 3 |
type Processor struct { Solver MathSolver } |
其中字段的类型为MathSolver
:
1 2 3 |
type MathSolver interface { Resolve(ctx context.Context, expression string) (float64, error) } |
稍后我们会实现并测试MathSolver
。
Processor
还需要一个从io.Reader
中读取表达式并返回计算值的方法:
1 2 3 4 5 6 7 8 9 10 11 12 |
func (p Processor) ProcessExpression(ctx context.Context, r io.Reader) (float64, error) { curExpression, err := readToNewLine(r) if err != nil { return 0, err } if len(curExpression) == 0 { return 0, errors.New("no expression to read") } answer, err := p.Solver.Resolve(ctx, curExpression) return answer, err } |
下面编写代码测试ProcessExpression
。首先,人们需要简单地实现Resolve
方法以供测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
type MathSolverStub struct{} func (ms MathSolverStub) Resolve(ctx context.Context, expr string) (float64, error) { switch expr { case "2 + 2 * 10": return 22, nil case "( 2 + 2 ) * 10": return 40, nil case "( 2 + 2 * 10": return 0, errors.New("invalid expression: ( 2 + 2 * 10") } return 0, nil } |
接下来,我们编写使用这一stub的单元测试(生产代码还应测试错误消息,但这里为保持简洁省略该操作):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
func TestProcessorProcessExpression(t *testing.T) { p := Processor{MathSolverStub{}} in := strings.NewReader(`2 + 2 * 10 ( 2 + 2 ) * 10 ( 2 + 2 * 10`) data := []float64{22, 40, 0} hasErr := []bool{false, false, true} for i, d := range data { result, err := p.ProcessExpression(context.Background(), in) if err != nil && !hasErr[i] { t.Error(err) } if result != d { t.Errorf("Expected result %f, got %f", d, result) } } } |
再进行测试,一切正常。
虽然大部分Go接口仅有一到两个方法,但也有更多的。有时会发现有多个方法的接口。我们来看第15章的GitHub代码库sample_code/stub目录中的代码。假设有一个这样的接口:
1 2 3 4 5 6 7 |
type Entities interface { GetUser(id string) (User, error) GetPets(userID string) ([]Pet, error) GetChildren(userID string) ([]Person, error) GetFriends(userID string) ([]Person, error) SaveUser(user User) error } |
在测试依赖于大型接口的代码,有两种模式。第一种是将接口内嵌到结构体中。在结构体中内嵌接口会自动在结构体中定义接口的所有方法。它不提供这些方法的具体实现,因此需要实现当前所需测试的方法。假设Logic
是一个包含Entities
类型字段的结构体:
1 2 3 |
type Logic struct { Entities Entities } |
假如想测试如下方法:
1 2 3 4 5 6 7 8 9 10 11 |
func (l Logic) GetPetNames(userId string) ([]string, error) { pets, err := l.Entities.GetPets(userId) if err != nil { return nil, err } out := make([]string, len(pets)) for _, p := range pets { out = append(out, p.Name) } return out, nil } |
这个方法仅使用对Entities
声明的一个方法,即GetPets
。不必实现GetPets
上的所有方法的stub来测试GetPets
,我们可以编写一个仅实现所需测试方法的stub结构体来完成测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
type GetPetNamesStub struct { Entities } func (ps GetPetNamesStub) GetPets(userID string) ([]Pet, error) { switch userID { case "1": return []Pet{{Name: "Bubbles"}}, nil case "2": return []Pet{{Name: "Stampy"}, {Name: "Snowball II"}}, nil default: return nil, fmt.Errorf("invalid id: %s", userID) } } |
然后编写单元测试,将stub插入Logic
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
func TestLogicGetPetNames(t *testing.T) { data := []struct { name string userID string petNames []string }{ {"case1", "1", []string{"Bubbles"}}, {"case2", "2", []string{"Stampy", "Snowball II"}}, {"case3", "3", nil}, } l := Logic{GetPetNamesStub{}} for _, d := range data { t.Run(d.name, func(t *testing.T) { petNames, err := l.GetPetNames(d.userID) if err != nil { t.Error(err) } if diff := cmp.Diff(d.petNames, petNames); diff != "" { t.Error(diff) } }) } } |
(顺便提下,GetPetNames
方法有一个bug。你发现了吗?即便是简单的方法有时也可能存在bug。)
警告:如在stub结构体中嵌入接口,请确保实现测试期间调用的所有方法!如调用未实现的方法,测试会panic。
如仅需为单个测试实现接口中的一个或两个方法,这种方法效果很好。但在需要对不同输入和输出的测试调用相同方法时,其缺点就会暴露出来。这时,需要在同一实现中包含每个测试的各种可能结果,或者为每个测试重新实现该结构体。这很快就会难以理解和维护。更好的解决方案是创建一个将方法调用代理到函数字段的stub结构体。对于Entities
上定义的每个方法,我们在stub结构体中定义一个具有匹配签名的函数字段:
1 2 3 4 5 6 7 |
type EntitiesStub struct { getUser func(id string) (User, error) getPets func(userID string) ([]Pet, error) getChildren func(userID string) ([]Person, error) getFriends func(userID string) ([]Person, error) saveUser func(user User) error } |
然后通过定义方法来让EntitiesStub
实现Entities
接口。在各方法中,我们调用相应函数字段。如:
1 2 3 4 5 6 7 |
func (es EntitiesStub) GetUser(id string) (User, error) { return es.getUser(id) } func (es EntitiesStub) GetPets(userID string) ([]Pet, error) { return es.getPets(userID) } |
创建好这一stub,就可以通过用于表格测试的数据结构体中的除非来支持不同测试用例中不同方法的实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
func TestLogicGetPetNames(t *testing.T) { data := []struct { name string getPets func(userID string) ([]Pet, error) userID string petNames []string errMsg string }{ {"case1", func(userID string) ([]Pet, error) { return []Pet{{Name: "Bubbles"}}, nil }, "1", []string{"Bubbles"}, ""}, {"case2", func(userID string) ([]Pet, error) { return nil, errors.New("invalid id: 3") }, "3", nil, "invalid id: 3"}, } l := Logic{} for _, d := range data { t.Run(d.name, func(t *testing.T) { l.Entities = EntitiesStub{getPets: d.getPets} petNames, err := l.GetPetNames(d.userID) if diff := cmp.Diff(petNames, d.petNames); diff != "" { t.Error(diff) } var errMsg string if err != nil { errMsg = err.Error() } if errMsg != d.errMsg { t.Errorf("Expected error `%s`, got `%s`", d.errMsg, errMsg) } }) } } |
我们在data
的匿名结构体中添加了一个函数类型的字段。在每个测试用例中,都指定一个返回与GetPets
相同数据的函数。通过这种方式编写测试桩,可以清楚地了解每个测试用例应该返回什么。每个测试运行时,我们都会实例化一个新的EntitiesStub
,并将测试数据中的getPets
赋值给EntitiesStub
中的getPets
函数字段。
模拟和桩测试
术语”模拟”(mock)和”桩”(stub)测试经常互换使用,但它们实际上是两个不同的概念。Martin Fowler,一个在与软件开发领域令人尊敬的前辈,写过一篇有关mock测试的博客文章,讲到了模拟和桩测试之间的区别。简言之,桩测试对给定的输入返回固定的值,而模拟测试则验证一组调用是否按照预期的顺序和预期的输入发生。
在示例中,我们使用测试桩来返回给定响应的固定值。读者可以手动编写自己的模拟测试,或者可以使用第三方库来生成。最流行的两个是Google的gomock库和Stretchr的testify库。
httptest
为调用HTTP服务的函数编写测试可能会很困难。过去这会成为一个集成测试,需要启动一个作为函数调用的服务的测试实例。Go标准库内置net/http/httptest
包,可以更容易地生成HTTP服务的测试桩。我们回到第15章的GitHub代码库的sample_code/solver目录,实现一个调用HTTP服务的MathSolver
来评估表达式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
type RemoteSolver struct { MathServerURL string Client *http.Client } func (rs RemoteSolver) Resolve(ctx context.Context, expression string) (float64, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, rs.MathServerURL+"?expression="+url.QueryEscape(expression), nil) if err != nil { return 0, err } resp, err := rs.Client.Do(req) if err != nil { return 0, err } defer resp.Body.Close() contents, err := io.ReadAll(resp.Body) if err != nil { return 0, err } if resp.StatusCode != http.StatusOK { return 0, errors.New(string(contents)) } result, err := strconv.ParseFloat(string(contents), 64) if err != nil { return 0, err } return result, nil } |
现在来看如何使用httptest
库在不启动服务端的情况下测试这段代码。代码位于第15章的GitHub代码库的solver/remote_solver_test.go中的TestRemoteSolver_Resolve
函数中,以下是要点。首先,我们希望保障传递给函数的数据到达服务端。因此,在测试函数中,我们定义了一个名为info
的类型来保存输入和输出,以及一个名为io
的变量,该变量被赋予当前的输入和输出值:
1 2 3 4 5 6 |
type info struct { expression string code int body string } var io info |
接着伪装启动一个远程服务端,使用它来配置RemoteSolver
的实例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
server := httptest.NewServer( http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { expression := req.URL.Query().Get("expression") if expression != io.expression { rw.WriteHeader(http.StatusBadRequest) fmt.Fprintf(rw, "expected expression '%s', got '%s'", io.expression, expression) return } rw.WriteHeader(io.code) rw.Write([]byte(io.body)) })) defer server.Close() rs := RemoteSolver{ MathServerURL: server.URL, Client: server.Client(), } |
httptest.NewServer
函数在随机未使用的端口上启动一个HTTP服务端。我们需要提供一个http.Handler
实现在处理请求。因其是服务端,必须在测试完成后关闭。http.Handler
实例的URL通过server
实例的URL
字段指定,以及有一个预配置的http.Client
与测试服务器之间进行通讯。我们将它们传递给RemoteSolver
。
函数剩下的部分与其它表格测试并无分别:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
data := []struct { name string io info result float64 }{ {"case1", info{"2 + 2 * 10", http.StatusOK, "22"}, 22}, // remaining cases } for _, d := range data { t.Run(d.name, func(t *testing.T) { io = d.io result, err := rs.Resolve(context.Background(), d.io.expression) if result != d.result { t.Errorf("io `%f`, got `%f`", d.result, result) } var errMsg string if err != nil { errMsg = err.Error() } if errMsg != d.errMsg { t.Errorf("io error `%s`, got `%s`", d.errMsg, errMsg) } }) } |
需要注意变量io
由两个不同的闭包捕获:一个用于桩服务器,一个用于运行各条测试。我们在一个闭包中写入、在另一个闭包中读取。在生产代码里这种做法不好,但在单个函数的测试代码完全成立。
集成测试和构建标签
虽然httptest
提供了一种不依赖外部服务的测试方式,但还是应该编写集成测试、连接其它服务的自动化测试。这些可以验证我们对服务API的理解是正确的。挑战是如何对自动化测试进行分组,只应在存在支撑环境时才运行集成测试。同时,集成测试一般比单元测试慢,所以不要频繁测试。
在Go语言工具中,我们讲到了构建标签,由Go编译器用于控制文件何时编译。虽然它们主要用于让开发者编写针对指定操作系统、CPU或Go版本的代码,但也可以利用其能力指定自定义标签来控制何时编译及运行测试。
让我们尝试使用我们的数学求解项目。通过docker pull jonbodner/math-server
使用Docker下载一个服务实现,然后在本地使用docker run -p 8080:8080 jonbodner/math-server
命令将服务运行在8080端口上。
注: 如果读者没有安装Docker,或者希望自行构建代码,可以在GitHub上找到相关代码。
我们需要编写一个集成测试,以确保我们的Resolve
方法正确地与数学服务器进行通信。第15章的GitHub代码库中的sample_code/solver/remote_solver_integration_test.go
文件中的TestRemoteSolver_ResolveIntegration
函数包含了一个完整的测试。这个测试看起来和我们之前编写的表格测试一样。要注意的是文件的第一行,包声明之前由一行分隔,如下所示:
1 |
//go:build integration |
与我们所编写的其它测试一同运行集成测试,使用:
1 |
$ go test -tags integration -v ./... |
使用
-short
标记另一种分组测试的方法是使用
go test
命令加-short
标记。如果希望跳过执行时间较长的测试,可以通过在测试函数开头添加以下代码来标记出慢速测试:
123 if testing.Short() {t.Skip("skipping test in short mode.")}在只希望运行短测试时,对
go test
传递-short
标记。使用
-short
标记运行短测试时需要注意一些问题。如果使用该标记,测试仅分为两个级别:短测试和所有测试。通过使用构建标签,可以对集成测试分组,指定它们运行需要使用的服务。另一个不使用-short
标记来表示集成测试的理由是逻辑上的。构建标签表示依赖关系,而-short
标记只是表示不希望运行耗时很长的测试。这是不同的概念。最后,我认为-short
标记不直观。始终应该运行短测试。更合理的做法是用一个标记来包含长时间运行的测试,而不是排除它们。
通过竞态检查器发现并发问题
虽然Go内置支持并发,还是会出现bug。很容易在不获取锁而误在两个不同的协程中引用同一变量。在计算机科学中这称为数据竞争(data race)。为有助找到这类bug,Go中内置了一个竞态检查器。它并不保证能找到代码中的每个数据竞争,如若找到,应对其添加适当的锁。
我们来看第15章的GitHub代码库中的简单示例sample_code/race/race.go :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
func getCounter() int { var counter int var wg sync.WaitGroup wg.Add(5) for i := 0; i < 5; i++ { go func() { for i := 0; i < 1000; i++ { counter++ } wg.Done() }() } wg.Wait() return counter } |
这段代码启动了5个协程,每个协程都对共享变量counter
进行1000次更新,然后返回结果。预期结果为5000,那么我们就用sample_code/race/race_test.go中的单元测试进行验证吧:
1 2 3 4 5 6 |
func TestGetCounter(t *testing.T) { counter := getCounter() if counter != 5000 { t.Error("unexpected counter:", counter) } } |
如果多次运行go test
,会发现有时会通过,但大多数时候会得到这样的错误消息:
1 |
unexpected counter: 3673 |
问题在于代码中存在数据竞争。在简单的程序中,原因很明显:多个协程尝试同时更新counter
而部分更新丢失了。在更复杂的程序中,这些竞争会更难发现。我们来看竞态检查器有什么功能。对go test
使用-race
标记来进行启用:
1 2 3 4 5 6 7 8 9 10 |
$ go test -race ================== WARNING: DATA RACE Read at 0x00c000128070 by goroutine 10: test_examples/race.getCounter.func1() test_examples/race/race.go:12 +0x45 Previous write at 0x00c000128070 by goroutine 8: test_examples/race.getCounter.func1() test_examples/race/race.go:12 +0x5b |
跟踪信息清晰地表明counter++
行是问题的根源。
警告: 有些人试图通过在代码中插入sleep来修复竞态条件,以将多个协程访问的变量的访问岔开。这种做法很糟糕。这样做可能在某些情况下消除问题,但代码仍然是错误的,在一些情况下会失败。
还可以在构建程序时使用-race
标记。这会创建一个包含竞态检查器的二进制文件,并将它找到的所有竞态报告到控制台。这样在没有测试的代码中可找到数据竞争。
竞态检查器这么有用,为什么不在所有测试和生产环境中始终启用它呢?启用-race
的二进制运行速度约比正常二进制慢10倍。对于需要几分钟才能运行的大型测试套件,这不什么是问题,但对于运行时间仅为一秒的测试套件来说,10倍慢的速度会降低生产效率。
模糊测试
每个开发人员最终都会学到的一项最重要的教训是所有数据都不可信。无论数据格式规范得多好,最终都将得处理与期望所不匹配的输入。这并不仅仅因恶意所致。数据在传输过程、存储甚至在内存中都可能受到损坏。处理数据的程序可能存在bug,而数据格式规范总会有一些边界情况,不同的开发人员的解释方式也会不同。
即使开发人员编写了良好的单元测试,也不可能考虑到所有情况。我们已经了解到,即使具有100%的单元测试覆盖率,也不能保证代码没有bug。需要用生成的数据来补充单元测试,这些数据可能会以预料外的方式破坏程序。这就用到了模糊测试。
模糊测试(Fuzzing)是一种生成随机数据并将其提交给代码以查看它是否正确处理意外输入的技术。开发人员可以提供一个种子语料库或一组已知的好数据,模糊测试器使用这些数据来生成有问题的输入。我们来看如何使用Go测试工具中的模糊测试来发现额外的测试用例。
假设我们正在编写一个处理数据文件的程序。示例代码位于GitHub上。我们发送了一个字符串列表,但希望高效地分配内存,因此文件中的字符串数以第一行发送,其余行为文本行。以下是处理该数据的示例函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
func ParseData(r io.Reader) ([]string, error) { s := bufio.NewScanner(r) if !s.Scan() { return nil, errors.New("empty") } countStr := s.Text() count, err := strconv.Atoi(countStr) if err != nil { return nil, err } out := make([]string, 0, count) for i := 0; i < count; i++ { hasLine := s.Scan() if !hasLine { return nil, errors.New("too few lines") } line := s.Text() out = append(out, line) } return out, nil } |
我们使用bufio.Scanner
逐行从io.Reader
中读取。如果没有供读取的数据,返回一个错误。然后读取第一行并尝试将其转化为命名为的整型count
。如转化失败,返回错误。接着,为字符串切片分配内存并从Scanner中读取count
行。如果行数不足,返回错误。一切正常的话,返回所读取的行数。
已编写了验证该代码的单元测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
func TestParseData(t *testing.T) { data := []struct { name string in []byte out []string errMsg string }{ { name: "simple", in: []byte("3\nhello\ngoodbye\ngreetings\n"), out: []string{"hello", "goodbye", "greetings"}, errMsg: "", }, { name: "empty_error", in: []byte(""), out: nil, errMsg: "empty", }, { name: "zero", in: []byte("0\n"), out: []string{}, errMsg: "", }, { name: "number_error", in: []byte("asdf\nhello\ngoodbye\ngreetings\n"), out: nil, errMsg: `strconv.Atoi: parsing "asdf": invalid syntax`, }, { name: "line_count_error", in: []byte("4\nhello\ngoodbye\ngreetings\n"), out: nil, errMsg: "too few lines", }, } for _, d := range data { t.Run(d.name, func(t *testing.T) { r := bytes.NewReader(d.in) out, err := ParseData(r) var errMsg string if err != nil { errMsg = err.Error() } if diff := cmp.Diff(out, d.out); diff != "" { t.Error(diff) } if diff := cmp.Diff(errMsg, d.errMsg); diff != "" { t.Error(diff) } }) } } |
单元测试对ParseData
有100%的行覆盖率,处理了所有的错误分支。你可能觉得代码已可以上生产,但我们来看模糊测试能否帮忙找到我们未考虑到的错误。
注:模糊测试消耗大量资源。一个模糊测试可能会分配(或尝试分配)好几G 的内存,并可能在本地磁盘上写几个 G 的内容。如果在该机器上同时运行的其它程序变慢了,请做好心理准备。
先来编写模糊测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
func FuzzParseData(f *testing.F) { testcases := [][]byte{ []byte("3\nhello\ngoodbye\ngreetings\n"), []byte("0\n"), } for _, tc := range testcases { f.Add(tc) } f.Fuzz(func(t *testing.T, in []byte) { r := bytes.NewReader(in) out, err := ParseData(r) if err != nil { t.Skip("handled error") } roundTrip := ToData(out) rtr := bytes.NewReader(roundTrip) out2, err := ParseData(rtr) if diff := cmp.Diff(out, out2); diff != "" { t.Error(diff) } }) } |
模糊测试与标准单元测试很像。函数名以Fuzz
开头,唯一的参数是*testing.F
类型,没有返回值。
接下来,我们配置一个种子语料库,由一到多个样本数据集组成。这些数据可以成功运行,也可以出错,甚至可能会panic。重要的是,你清楚提供这些数据时程序的行为,并且模糊测试会考虑到这种行为。这些样本数据会由模糊测试器修改生成不良输入。我们的示例只使用了每个条目的一个数据字段(一个字节切片),但你可以使用尽可能多的字段。目前,语料库条目中的字段仅限于以下类型:
- 任意整数类型(包括无符号类型、
rune
和byte
) - 任意浮点数类型
bool
string
[]byte
语料库中的每个条目都传递给*testing.F
实例上的Add
方法。在本例中,每个条目都是一个字节切片:
1 |
f.Add(tc) |
如果进行模糊测试的函数需要一个int
和string
,对Add
的调用就会是这样:
1 |
f.Add(1, "some text") |
向Add
传递无效类型的值报运行时错误。
接下来,我们在*testing.F
实例上调用Fuzz
方法。这与编写标准单元测试中的表格测试时调用Run
有点像调。Fuzz
接受一个参数,一个函数,其第一个参数的类型为*testing.T
,其余参数的类型、顺序和数量与传递给Add
的值完全匹配。这也指定了在模糊测试期间由模糊测试引擎生成的数据类型。Go编译器无法强制执行这个约束,因此如果未遵循这个约定,就会导致运行时错误。
最后,让我们看一下模糊测试的主体。记住,模糊测试用于查找无法正确处理不良输入的情况。由于输入是随机生成的,我们无法编写输出具体是什么的测试。相反,我们必须使用对所有输入都为真的测试条件。对于ParseData
来说,可以检查两类:
- 代码是否会对不良输入返回错误,或者是否会panic?
- 如果你将字符串切片转换回字节切片并重新解析它,是否会得到相同的结果?
我们来看运行模糊测试时会发生什么:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
$ go test -fuzz=FuzzParseData fuzz: elapsed: 0s, gathering baseline coverage: 0/243 completed fuzz: elapsed: 0s, gathering baseline coverage: 243/243 completed, now fuzzing with 8 workers fuzz: minimizing 289-byte failing input file fuzz: elapsed: 3s, minimizing fuzz: elapsed: 6s, minimizing fuzz: elapsed: 9s, minimizing fuzz: elapsed: 10s, minimizing --- FAIL: FuzzParseData (10.48s) fuzzing process hung or terminated unexpectedly while minimizing: EOF Failing input written to testdata/fuzz/FuzzParseData/ fedbaf01dc50bf41b40d7449657cdc9af9868b1be0421c98b2910071de9be3df To re-run: go test -run=FuzzParseData/ fedbaf01dc50bf41b40d7449657cdc9af9868b1be0421c98b2910071de9be3df FAIL exit status 1 FAIL file_parser 10.594s |
如未指定-fuzz
标志,模糊测试将被视作单元测试,并以种子语料库运行。一次只能对一个模糊测试进行模糊测试。
注: 如想要完整体验,可以删除
testdata/fuzz/FuzzParseData
目录的内容。这会使用模糊测试器生成新的种子语料库条目。由于模糊测试器生成随机输入,样本可能与所显示的不同。不过,不同的条目可能会产生类似的错误,虽然顺序可能不同。
模糊测试运行了几秒钟,然后失败了。在这种情况下,go
命令报告它已崩溃。我们不希望程序崩溃,因此来看一下生成的输入。每次测试用例失败时,模糊测试器都会将它写入与失败的测试相同包中的testdata/fuzz/TESTNAME
子目录中,在种子语料库中添加一个新的条目。文件中的新种子语料库条目现在成为一个新的单元测试,由模糊测试器自动生成。每当go test
运行FuzzParseData
函数时,它都会运行,并在我们修复了错误后充当回归测试。
以下是文件的内容:
1 2 |
go test fuzz v1 []byte("300000000000") |
第一行表示模糊测试的测试数据的头。后续行为导致错误的数据。
错误消息表明在重新运行测试时如何隔离出错的分支:
1 2 3 4 |
$ go test -run=FuzzParseData/ fedbaf01dc50bf41b40d7449657cdc9af9868b1be0421c98b2910071de9be3df signal: killed FAIL file_parser 15.046s |
问题是我们在尝试分配一个能存储300,000,000,000字符串容量的切片。所需的RAM比我电脑的要多。我们需要将预期的文本元素限定到合适的数量。通过在ParseData
中解析预期行数之后添加如下代码将最大行数设置为1,000:
1 2 3 |
if count > 1000 { return nil, errors.New("too many") } |
再测试运行模糊测试查看是否有其它错误:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
$ go test -fuzz=FuzzParseData fuzz: elapsed: 0s, gathering baseline coverage: 0/245 completed fuzz: elapsed: 0s, gathering baseline coverage: 245/245 completed, now fuzzing with 8 workers fuzz: minimizing 29-byte failing input file fuzz: elapsed: 2s, minimizing --- FAIL: FuzzParseData (2.20s) --- FAIL: FuzzParseData (0.00s) testing.go:1356: panic: runtime error: makeslice: cap out of range goroutine 23027 [running]: runtime/debug.Stack() /usr/local/go/src/runtime/debug/stack.go:24 +0x104 testing.tRunner.func1() /usr/local/go/src/testing/testing.go:1356 +0x258 panic({0x1003f9920, 0x10042a260}) /usr/local/go/src/runtime/panic.go:884 +0x204 file_parser.ParseData({0x10042a7c8, 0x14006c39bc0}) file_parser/file_parser.go:24 +0x254 [...] Failing input written to testdata/fuzz/FuzzParseData/ 03f81b404ad91d092a482ad1ccb4a457800599ab826ec8dae47b49c01c38f7b1 To re-run: go test -run=FuzzParseData/ 03f81b404ad91d092a482ad1ccb4a457800599ab826ec8dae47b49c01c38f7b1 FAIL exit status 1 FAIL file_parser 2.434s |
这次的测试结果中产生了panic。查看go fuzz
生成的文件,可以看到:
1 2 |
go test fuzz v1 []byte("-1") |
导致panic的行为:
1 |
out := make([]string, 0, count) |
我们在尝试创建容量为负数的切片,产生了panic。在代码添加一个条件发现负数的情况:
1 2 3 |
if count < 0 { return nil, errors.New("no negative numbers") } |
再次运行测试,会出现另一个错误:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
$ go test -fuzz=FuzzParseData fuzz: elapsed: 0s, gathering baseline coverage: 0/246 completed fuzz: elapsed: 0s, gathering baseline coverage: 246/246 completed, now fuzzing with 8 workers fuzz: elapsed: 3s, execs: 288734 (96241/sec), new interesting: 0 (total: 246) fuzz: elapsed: 6s, execs: 418803 (43354/sec), new interesting: 0 (total: 246) fuzz: minimizing 34-byte failing input file fuzz: elapsed: 7s, minimizing --- FAIL: FuzzParseData (7.43s) --- FAIL: FuzzParseData (0.00s) file_parser_test.go:89: []string{ - "\r", + "", } Failing input written to testdata/fuzz/FuzzParseData/ b605c41104bf41a21309a13e90cfc6f30ecf133a2382759f2abc34d41b45ae79 To re-run: go test -run=FuzzParseData/ b605c41104bf41a21309a13e90cfc6f30ecf133a2382759f2abc34d41b45ae79 FAIL exit status 1 FAIL file_parser 7.558s |
查看所创建的文件,生成的是仅包含\r(回车)字符的空行。我们没考虑输入中有空行,所以在读取Scanner
中文本行的循环中添加一些代码。我们会检测某行是否仅包含空白字符。如是,则返回错误:
1 2 3 4 |
line = strings.TrimSpace(line) if len(line) == 0 { return nil, errors.New("blank line") } |
再次运行模糊测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
$ go test -fuzz=FuzzParseData fuzz: elapsed: 0s, gathering baseline coverage: 0/247 completed fuzz: elapsed: 0s, gathering baseline coverage: 247/247 completed, now fuzzing with 8 workers fuzz: elapsed: 3s, execs: 391018 (130318/sec), new interesting: 2 (total: 249) fuzz: elapsed: 6s, execs: 556939 (55303/sec), new interesting: 2 (total: 249) fuzz: elapsed: 9s, execs: 622126 (21734/sec), new interesting: 2 (total: 249) [...] fuzz: elapsed: 2m0s, execs: 2829569 (0/sec), new interesting: 16 (total: 263) fuzz: elapsed: 2m3s, execs: 2829569 (0/sec), new interesting: 16 (total: 263) ^Cfuzz: elapsed: 2m4s, execs: 2829569 (0/sec), new interesting: 16 (total: 263) PASS ok file_parser 123.662s |
几分钟后,不再有报错,按下control+C终止测试。
模糊测试没有找到其它问题也并不表示代码就没有bug了。但模糊测试让我们可以找到原始代码中忽略的一些错误。编写模糊测试需要一些练习,因其与编写单元测试的思维不同。一旦掌握,就会成为验证代码如何处理预料外用户输入的基本工具。
小结
本章中,我们学习了如何通过Go对测试、代码覆盖率、基准测试、模糊测试和数据竞争检查的内置支持编写测试及提升代码质量。