在 Go 中重现不稳定的测试
作者:Mark Rushakoff / 用例, 开发者
2019 年 8 月 15 日
导航至
通常,在持续集成 (CI) 中偶尔失败的测试可以在本地通过简单的 go test -count=N ./path/to/flaky/package
重现……但有时,它就是无法在本地重现。或者,该软件包的完整测试套件耗时太长,并且测试失败的频率很低,以至于您需要一种更精确的方法来精确定位错误的测试。
自信地修复测试失败远比做出好的猜测并寄希望于最好要好得多。如果您不得不在另一天再次尝试修复它,您将浪费时间重新收集您第一次拥有的上下文。
在本文档中,我将详细介绍我发现有效的方法,以便系统地重现测试失败,这是我在追踪许多不稳定测试中花费的许多小时的经验总结。
但首先,让我们看看导致不稳定测试的常见模式。
不稳定测试类别
最终,不稳定的测试是非确定性测试。根据我的经验,不稳定性的根本原因通常分为两类。
非确定性数据
对于不稳定的测试,有两种有趣的非确定性数据类型:数据本身是一致的,但以非确定性方式访问;以及数据是随机生成的,但以确定性方式访问。当然,这两种类型并非互斥。
非确定性访问
一个测试如果每次访问数据的方式都不相同是可以的,只要测试的断言考虑了数据可能以不同的顺序排列。
大多数 Go 开发者都知道 map 迭代顺序是随机的。迭代 map 以填充切片,然后断言该切片的顺序——特别是当 map 只有两三个元素时——是一种测试模式,它通常会令人惊讶地通过。将此与 Go 的测试缓存以及盲目重新运行失败的持续集成 (CI) 作业的人类习惯相结合,您将得到一个完美且烦人的测试失败,它很少出现。
除了 map 迭代之外,执行并发工作的 goroutine 可能会以任意顺序完成。也许您有两个 goroutine,一个读取小文件,另一个读取大文件,并且每个 goroutine 都在同一通道上发送一些结果。大多数情况下,小文件的 goroutine 将首先完成并发送其结果;如果您的测试假设总是如此,那么测试有时会失败。
非确定性生成
在测试中充分利用随机数据本身就是一门艺术。go-fuzz 是一个出色的工具,用于发现与处理任意输入相关的错误。在测试中使用随机值是一种轻量级的方式,可以潜在地以类似的方式发现错误,但缺点是您只有在测试偶尔失败时才会了解到该错误。因此,重要的是您可以轻松获取导致失败的输入。
我追踪到的唯一一个不稳定的测试,可能是在六年前,在我脑海中最突出的是涉及反序列化随机填充的 YAML 文件。我们偶尔会看到此测试失败并出现神秘的失败消息,再次运行它总是会通过。我们正在为特定值随机生成十六进制字符的字符串。大多数情况下,YAML 看起来像 key: a1b2c3
并且会被解释为字符串……但偶尔它会选择一个全十进制数字序列,然后是单个字母 E,然后再是其余的十进制数字。我们没有用引号将值括起来,因此解析器会将 key: 12345e12
解释为浮点数而不是字符串!
当使用随机生成的值时,请确保可以轻松恢复导致失败的输入。通常,您可以将该值包含在对 t.Fatalf
的调用中。
如果这是一个更复杂的测试,涉及许多文件,每个文件都包含一些随机内容,我至少会将所有文件放在同一个临时目录中。这样,如果我需要重现失败以检查文件,我可以注释掉对 os.RemoveAll
的调用,并添加一个 t.Log(dirName)
以了解在哪里探索以查看错误的输入。如果您已经在本地重现间歇性故障,那么对测试函数进行一些临时编辑,我认为没什么问题。
基于定时的测试
根据我的经验,对时间敏感的测试比前面提到的数据顺序逻辑错误更容易导致不稳定的失败。
通常情况是这样的:您的测试启动一个 goroutine 来执行一个应该快速完成的操作,可能在几十毫秒内。您选择一个合理的超时值——“如果我在 100 毫秒内没有看到结果,则测试失败。” 您在您的机器上循环运行该测试 15 分钟,并且每次都通过。然后在将该更改合并到主分支后,该测试在第一天在持续集成 (CI) 上运行时至少失败一次。有人将超时时间增加到一秒钟,但它仍然设法每周失败几次。现在怎么办?
如果您有一种方法可以测试同步 API 而不是异步 API——避免对时间敏感——这通常是最好的解决方案。
如果您必须异步测试,请务必轮询而不是进行单次长时间睡眠。不要这样做
go longOperation.Start()
// Bad: this will always eat up 5 seconds in test, even if the operation completes instantly.
time.Sleep(5 * time.Second)
if !longOperation.IsDone() {
t.Fatal("didn't see result in time")
}
res := longOperation.Result()
if res != expected {
t.Fatalf("expected %v, got %v", expected, res)
}
相反,对于允许您检查调用是否会阻塞的 API,我经常使用如下模式
go longOperation.Start()
deadline := time.Now().Add(5 * time.Second)
for {
if time.Now().After(deadline) {
t.Fatal("didn't see result in time")
}
if !longOperation.IsDone() {
time.Sleep(100 * time.Millisecond)
continue
}
res := longOperation.Result()
if res != expected {
t.Fatalf("expected %v, got %v", expected, res)
}
}
如果 API 是阻塞的,但接受可取消的 context,您应该使用合理的超时时间,以便测试比默认的 10 分钟超时时间更快地失败
go longOperation.Start()
ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)
defer cancel()
res, err := longOperation.WaitForResult(ctx)
if err != nil {
t.Fatal(err)
}
if res != expected {
t.Fatalf("expected %v, got %v", expected, res)
}
如果 API 阻塞但不接受 context,您可以编写一个辅助函数来运行该方法,如果它未在给定的超时时间内完成,则使其失败(留给读者作为练习)。
在您的工作站上重现不稳定的测试
您已经在持续集成 (CI) 上看到测试失败了几次。如果您重新运行持续集成 (CI) 作业,它通常会通过,但与其推诿拖延,不如现在就修复它。在您可以自信地说您已修复测试之前,您需要自信地在本地重现测试。
设置您的测试循环
首先,专注于失败的确切软件包。也就是说,您要运行 go test ./mypkg
,而不是 go test ./...
。
然后,使用 -run
专注于失败的确切测试。通常,我只是复制并粘贴正在失败的测试名称,例如 go test -run=TestFoo ./mypkg
。但是,请注意 -run
标志接受正则表达式,因此如果您的测试名称也是另一个测试的前缀,您可以通过锚定名称(如 go test -run='^TestFoo$' ./mypkg
)来确保您只运行完全匹配的测试。
如果您多次运行它,您肯定会注意到最新版本的 Go 会缓存测试结果。显然,当我们尝试重现不稳定的测试时,我们不希望这样做。如果您只是在不使用缓存的情况下运行完整的测试套件,您可以使用 -count=1
,但您应该选择更大的数字。该数字将因确切的测试而异;我个人的偏好是计数在 10 秒左右完成。假设我们确定为 100——您的命令现在看起来像 go test -run=TestFoo -count=100 ./mypkg
。
-count
标志将运行指定的迭代次数,无论有多少次运行失败。大多数情况下,您不会从单次运行中的多次失败中获得任何额外信息。因此,我更喜欢使用 -failfast
标志,以便测试过程在第一次失败的运行后停止。
现在,我们可以将此放入一个非常简单的 bash 循环中:while go test -run=TestFoo -count=100 -failfast ./mypkg; do date; done
。您可以将任何内容放入循环体中,但我喜欢在输出中看到日期过去,以便我可以查看它是否已死锁。(如果您尝试重现的测试失败本身就是死锁,那么这个大约 10 秒完成的循环与 -timeout=20s
搭配使用效果很好,这样您就不必等待大约 10 分钟才能看到堆栈跟踪。)
此时,如果您可以在一分钟或更短的时间内重现失败,那么您就可以很好地修复测试了。如果重现所需的时间长得多,您可以通过在循环之前编译测试包来节省更多时间。在底层,go test
将编译并运行测试包,因此我们可以通过自己编译来避免一些重复工作。当我们直接执行编译后的测试包时,我们需要在特定于测试的标志上使用 test.
前缀,如下所示:go test -c ./mypkg && while ./mypkg.test -test.run=TestFoo -test.count=100 -test.failfast; do date; done
。
当该测试循环仍然无法重现不稳定的测试时
使用数据竞争检测器
有时,测试失败是一个基于时间的硬性截止日期,例如等待一秒钟才能发生某些事情。使用标志 -race
启用竞争检测器编译测试通常会减慢执行速度,这可能足以重现失败。如果您碰巧在此过程中检测到任何新的数据竞争,那也很棒。
停止关注测试
这对于数据排序的不稳定性比基于时间的不稳定性更有帮助。
在极少数情况下,不稳定测试的失败与另一个测试的污染有关。您可以完全删除 -run
标志并运行该循环一段时间,看看测试是否失败。然后,您可以逐渐跳过越来越多的通过测试,直到确定哪些测试正在对不稳定的测试造成污染。添加 t.Skip
调用有效,但对于这种临时更改,我通常将 TestBar
重命名为 xTestBar
,以便 Go 的测试检测停止注意到该测试,并且我不必在详细输出中看到 SKIP
行。
限制进程的 CPU 使用率
一些不稳定的测试似乎只在资源争夺的环境(如您的持续集成 (CI) 服务器)中出现,而永远不会在像您的工作站这样的空闲系统上出现。为了重现这些类型的失败,我们在 cpulimit 上取得了不错的成果,它应该在您的标准软件包管理器中可用(例如 brew install cpulimit
或 apt install cpulimit
)。
cpulimit 并没有什么神奇之处。如果您曾经在终端中按下 control-Z 来停止正在运行的进程,并使用 fg
恢复它,那么您基本上熟悉了 cpulimit 的工作原理:它重复发送 SIGSTOP 以暂停进程,并发送 SIGCONT 以再次恢复它,从而有效地限制进程允许使用的 CPU 时间。对于大多数用例,这与有限的 CPU 非常接近;并且 cpulimit 工具具有跨平台且易于使用的优点。
cpulimit 的有趣标志是
-l
/--limit
,用于控制可用的 CPU 量-i
/--include-children
应用于目标进程的子进程-z
/--lazy
如果目标进程死亡则退出
为 --limit
选择合适的值主要是反复试验。值太低可能会导致运行时间过长和意外超时,但值太高不一定会重现问题。请记住,最大值是系统上可用核心数的 100 倍,因此 -l 100
不代表可用 CPU 的 100%,而是代表一个核心的 100%。
然后,您通常希望在 cpulimit 下运行编译后的测试包,例如 cpulimit -l 50 -i -z ./mypkg.test -test.run=TestFoo -test.count=100
。如上所述,最好使用 go test -c
在带外构建您的测试包,而无需使用 cpulimit。如果您要运行 cpulimit -l 50 go test ./mypkg -run=TestFoo -count=100
——请注意缺少 -i
标志——您将限制 go test
,但 mypkg.test
子进程将在不受限制的情况下运行。
调整 Go 运行时的并发性
您可以按照 runtime 包文档中的说明设置 GOMAXPROCS 环境变量
GOMAXPROCS 变量限制了可以同时执行用户级 Go 代码的操作系统线程数。可以代表 Go 代码在系统调用中阻塞的线程数没有限制;这些线程不计入 GOMAXPROCS 限制。
当尝试重现不稳定的测试时,仅当您已经怀疑不稳定性与并发性有关时,此设置通常才有用。
通常只有三个有趣的 GOMAXPROCS 设置
- 一个,它将只允许一个 goroutine 在任何时刻执行
- 默认设置,即可用逻辑 CPU 的数量
- 将您系统上的 CPU 数量增加一倍(或更多),以尝试引入更多共享资源的争用
以我的经验来看,似乎受 GOMAXPROCS 影响的不稳定测试,在一种极端情况下永远不会重现,在默认情况下偶尔重现,而在另一种极端情况下经常重现。这取决于特定的故障模式;有些测试在争用过多时可能不稳定,而另一些测试在没有足够的 goroutine 处理工作时可能不稳定。
结论
重现不稳定测试既是科学,也是艺术。我希望这些技巧能在您下次处理一直失败并在不方便的时候阻止 CI 的测试时为您提供帮助。