在Go中重现不可靠的测试

导航到

通常,CI中偶尔失败的测试可以在本地通过简单的go test -count=N ./path/to/flaky/package重现……但有时它只是在本地无法重现。或者,也许该包的完整测试套件耗时过长,测试失败的概率极低,需要更精确的方法来定位有问题的测试。

自信地修复测试失败比做出好猜测并寄希望于最佳结果要好得多。如果您不得不在另一天再次尝试修复它,您将浪费重新收集第一次有时的上下文的时间。

在本文档中,我将详细说明我在追踪许多不可靠测试过程中找到的有效方法,以系统地重现测试失败。

但首先,让我们看看导致不可靠测试的常见模式。

不可靠测试分类

最终,不可靠测试是非确定性的测试。根据我的经验,不可靠性的根本原因通常分为两类。

非确定性数据

在不可靠测试中,有两种有趣类型的非确定性数据:本身一致但访问非确定性的数据,以及随机生成但访问确定性的数据。当然,这两种类型不是相互排斥的。

非确定性访问

测试每次不以相同方式访问其数据是可以的,只要测试的断言考虑了数据可能处于不同的顺序。

大多数Go开发者都知道映射迭代顺序是随机的。迭代映射以填充切片,然后断言该切片的顺序——特别是当映射只有两个或三个元素时——是一种测试模式,它会意外地通过很多次。结合Go的测试缓存和人类盲目重新运行失败的CI作业的习惯,您将有一个非常讨厌的测试失败,它很少出现。

除了映射迭代之外,执行并发工作的goroutines可能以任意顺序完成。也许您有两个goroutines,一个读取一个非常小的文件,另一个读取一个非常大的文件,每个都向同一个通道发送一些结果。大多数时候,小文件的goroutine会先完成并发送其结果;如果您的测试假设这总是这样,那么测试有时会失败。

非确定性生成

在测试中充分利用随机数据本身就是一门艺术。《go-fuzz》(https://github.com/dvyukov/go-fuzz)是一个出色的工具,用于发现与任意输入处理相关的错误。在测试中使用随机值是一种轻量级的方法,可能会以类似的方式发现错误,但缺点是只有在测试偶尔失败时才会了解到错误。因此,能够轻松获取导致失败的数据输入非常重要。

我追踪到的唯一一个不可靠的测试,大约六年前,最令我印象深刻的是涉及反序列化一个随机填充的 YAML 文件。我们很少看到这个测试以一个神秘的错误消息失败,再次运行它总是会通过。我们会随机生成一个特定值的十六进制字符字符串。大多数时候,YAML 会像 key: a1b2c3 这样,会被解释为一个字符串……但是偶尔它会选择一个全部由数字组成的序列,然后是一个单独的字母 E,然后是其余的数字。我们没有用引号包围这个值,所以解析器会将 key: 12345e12 解释为浮点数而不是字符串!

当使用随机生成的值时,请确保可以轻松恢复导致失败的数据输入。通常,您可以将该值包含在 t.Fatalf 调用中。

如果这是一个更复杂的测试,涉及许多包含一些随机内容的文件,我至少会把所有文件放在同一个临时目录中。这样,如果我需要重现失败以检查这些文件,我只需取消对 os.RemoveAll 调用的注释,并添加一个 t.Log(dirName) 来了解在哪里探索以查看不良输入。如果您已经正在努力在本地重现间歇性失败,那么对测试函数进行一些临时编辑似乎也没有什么问题。

基于时间的测试

在我的经验中,对时间敏感的测试往往比之前提到的关于数据顺序的逻辑错误更频繁地导致不可靠的失败。

通常是这样的:您的测试启动一个 goroutine 来执行一个应该很快完成的操作,可能只有几十毫秒。您选择一个合理的超时值——“如果我100毫秒内看不到结果,测试就失败了。”您在您的机器上运行这个测试15分钟,每次都通过。然后,在将更改合并到主分支后,测试在CI上运行的第一天至少失败了一次。有人将超时值提高到1秒,但它仍然每周会失败几次。现在怎么办?

如果您有一种方法可以测试同步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是阻塞的,但接受可取消的上下文,您应该使用合理的超时,以便测试能够比默认的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是阻塞的,但不接受上下文,您可以编写一个辅助函数来运行该方法,并在给定超时时间内未完成时失败(留给读者练习)。

在您的工作站上重现不可靠的测试

您已经在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 分钟才能看到堆栈跟踪。)

到这一点,如果你能在 1 分钟或更短的时间内重现失败,你就有很好的机会修复这个测试。如果需要更长时间才能重现,你可以在循环之前编译测试包来节省更多时间。在内部,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 cpulimitapt 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 并不代表100%的可用CPU,而是100%的一个核心。

然后,通常您会希望使用cpulimit运行编译后的测试包,例如cpulimit -l 50 -i -z ./mypkg.test -test.run=TestFoo -test.count=100。如上所述,建议使用不带cpulimit的go test -c在单独的线程中构建测试包。如果您运行cpulimit -l 50 go test ./mypkg -run=TestFoo -count=100 — 注意缺少的-i标志 — 您会限制go test,但mypkg.test 子进程将不受限制地运行。

调整Go运行时的并发性

您可以根据运行时包文档中所述设置GOMAXPROCS环境变量

GOMAXPROCS变量限制了可以同时执行用户级Go代码的操作系统能够执行的线程数。对于代表Go代码在系统调用中阻塞的线程数没有限制;这些不计入GOMAXPROCS限制。

在尝试重现脆弱性测试时,此设置通常仅在您已经怀疑脆弱性可能与并发相关时才有用。

对于GOMAXPROCS通常只有三个有趣的设置

  • 一个,这将在任何时刻只允许一个goroutine执行
  • 默认设置,这是可用的逻辑CPU数量
  • 系统CPU数量的两倍(或更多),以尝试引入更多共享资源的竞争

根据我的经验,那些似乎受GOMAXPROCS影响的易失性测试往往在极端情况下无法重现,偶尔在默认情况下重现,而在另一极端情况下经常重现。这取决于特定的故障模式;有些测试在竞争过于激烈时可能会变得易失性,而其他测试在goroutines处理工作不足时可能会变得易失性。

结论

重现易失性测试既是科学又是艺术。希望这些建议能帮助您在下一次处理不断失败的测试并阻塞CI时。