最近,我花了几个小时来理解为什么一个项目的 API 测试很慢。理论上,我们设计测试以完全并行的方式运行 - 测试的持续时间应该接近最长运行的测试。但不幸的是,现实情况却不同。测试耗时是最慢测试的 7 倍,而且没有使用 100% 的可用资源。
在本文中,我将向您展示一些技术,帮助您理解和优化测试执行。优化使用 CPU 的测试很简单(大多数情况下只需添加更多资源)。我们将重点关注优化单线程、CPU 密集型集成测试、组件测试、API 测试和端到端测试的场景。
看不见的问题难以修复
从 go test
生成的输出很难理解测试是如何运行的。你可以看到每个测试花了多长时间,但你不知道一个测试等待运行了多长时间。你也看不到有多少测试并行运行。 当你的项目有数千个测试时,这个问题会变得更加困难。
令人惊讶的是,我没有找到任何可以帮助可视化 Go 测试执行的工具。作为一个业余的前端工程师,我决定在一个周末自己构建一个。
vgt
[1] - 可视化 Go 测试的缺失工具
vgt
[0] 可以解析 JSON 格式的 Go 测试输出来创建可视化。最快的使用方法是调用:
go test -json ./... | go run github.com/roblaszczak/vgt@latest
或者通过安装并运行:
go install -u github.com/roblaszczak/vgt go test -json ./... | vgt
在理想情况下,非 CPU 密集型测试的执行应该是这样的:
所有非 CPU 密集型测试都并行执行。输出来自 vgt[0] 。
每个条形图代表单个测试或子测试的执行时间。不幸的是,我最近调试的测试看起来更像这样:
这些测试可以优化。输出来自 vgt[0] 。
虽然 CPU 没有被完全使用,但测试是一个接一个运行的。这也是一个好兆头:我们有很大的改进空间。
Go 测试的并行化
默认情况下,Go 中同一个包内的所有测试都是按顺序运行的。当测试有效地使用所有 CPU 核心并且测试被分割到多个包中时,这不是问题。但是当数据库查询、API 调用或休眠阻塞测试时,我们的 CPU 会浪费周期 - 特别是当我们在一个包中有很多测试时。
没有 t.Parallel() 标志的测试。输出来自 vgt[0] 。
要解决这个问题, *testing.T
[2] 提供了 t.Parallel()
[3] 方法,允许测试和子测试并行运行。
t.Parallel()
不是万能的,使用时要谨慎。
只对有阻塞操作的测试使用 t.Parallel()
,比如数据库查询、API 调用、休眠等。 对于只使用单核的 CPU 密集型测试也可能有意义。
对于快速的单元测试,使用 t.Parallel()
的开销会高于顺序运行。换句话说,对轻量级单元测试使用 t.Parallel()
可能会使它们变慢。
并行度限制
即使你使用了 t.Parallel()
,也不意味着所有测试都会并行运行。为了模拟这种情况,我写了一个示例测试,模拟 100 个进行 API 调用的测试。
func TestApi_parallel_subtests(t *testing.T) { t.Parallel() for i := 0; i < 100; i++ { t.Run(fmt.Sprintf("subtest_%d", i), func(t *testing.T) { t.Parallel() simulateSlowCall(1 * time.Second) }) } } func simulateSlowCall(sleepTime time.Duration) { time.Sleep(sleepTime + (time.Duration(rand.Intn(1000)) * time.Millisecond)) }
只要目标服务器没有过载并且测试设计得当,我们应该能够并行运行所有测试。在这种情况下,运行所有测试最多应该需要 2 秒。但实际上却花了超过 16 秒。
尽管使用了 t.Parallel()
,执行图显示了许多代表暂停的灰色条。标记为 PAUSED
的测试由于并行度限制而受限。
这些测试可以优化。输出来自 vgt[0] 。
首先,让我们理解使用 t.Parallel()
的测试是如何运行的。如果你好奇,可以查看 testing
[4] 包的源代码。重要的是,测试并行度默认设置为 runtime.GOMAXPROCS(0)
,它返回操作系统报告的核心数。
parallel = flag.Int("test.parallel", runtime.GOMAXPROCS(0), "run at most `n` tests in parallel")
在我的 Macbook 上,runtime.GOMAXPROCS(0)
返回 10(因为我有 10 核 CPU)。换句话说,它限制并行运行的测试数量为 10。
当测试是 CPU 密集型时,将测试限制在我们的核心数量是有意义的。更多的并行度会迫使我们的操作系统进行更昂贵的上下文切换。但是当我们调用数据库、API 或任何阻塞 I/O 时,它会在不充分利用资源的情况下使测试变得更长。
当 API 测试在 CI 中针对部署在单独的 VM 或云中的环境运行时,情况可能会更糟。通常,API 测试的 CI 运行器可能只有 1-2 个 CPU。使用 1 个 vCPU,API 测试将一个接一个地运行。我们可以通过设置环境变量 GOMAXPROCS=1
来模拟这种情况。
使用 1 个 vCPU 的测试 - 这可能是 CI 中的情况。输出来自 vgt[0] 。
并行度实际上被设置为 1,所以我们看到很多代表等待时间的灰色条。要解决这个问题,我们可以使用 -parallel
(或 -test.parallel
- 它们有相同的效果)标志。 Go 文档说[5] :
-parallel n Allow parallel execution of test functions that call t.Parallel, and fuzz targets that call t.Parallel when running the seed corpus. The value of this flag is the maximum number of tests to run simultaneously. While fuzzing, the value of this flag is the maximum number of subprocesses that may call the fuzz function simultaneously, regardless of whether T.Parallel is called. By default, -parallel is set to the value of GOMAXPROCS. Setting -parallel to values higher than GOMAXPROCS may cause degraded performance due to CPU contention, especially when fuzzing. Note that -parallel only applies within a single test binary. The 'go test' command may run tests for different packages in parallel as well, according to the setting of the -p flag (see 'go help build').
不要将 GOMACPROCS
更改为高于可用核心数的值来强制更多并行度。
这将产生比仅仅影响测试更多的效果 - 它将产生比核心更多的 Go 线程。这将导致更昂贵的上下文切换,可能会减慢 CPU 密集型测试。
让我们看看使用额外的 -parallel 100
标志时相同的测试会如何表现:
go test ./… -json -parallel=100 | vgt
我们达到了目标 - 所有测试都并行运行。我们的测试不是 CPU 密集型的,所以整体执行时间可以和最慢执行的测试一样长。
如果你没有更改测试代码并想测试它们的性能,Go 可能会缓存它们。
为了避免缓存,使用 -count=1
标志运行它们,例如 go test ./... -json -count=1 -parallel=100 | vgt
。
多包中的测试
使用 -parallel
标志并不是我们可以做的唯一加速测试的方法。将测试存储在多个包中并不罕见。默认情况下,Go 限制可以同时运行的包的数量为核心数量。 让我们看看示例项目结构:
$ ls ./tests/* ./tests/package_1: api_test.go ./tests/package_2: api_test.go ./tests/package_3: api_test.go
通常,对于更复杂的项目,我们可能有更多带测试的包。为了可读性,让我们模拟测试如何在一个 CPU 上运行(例如,在一个有 1 个核心的 CI 运行器中):
GOMAXPROCS=1 go test ./tests/... -parallel 100 -json | vgt
在有 3 个包的 1 核运行器上运行测试。输出来自 vgt[0] 。
你可以看到每个包都是单独运行的。我们可以用 -p
标志来解决这个问题。如果你的测试在多核机器上运行,并且你没有很多包含长时间运行测试的包,这可能不是问题。 但在我们的场景中,使用 CI 和一个核心,我们需要指定 -p
标志。它将允许最多 16 个包并行运行。
GOMAXPROCS=1 go test ./tests/... -parallel 128 -p 16 -json | vgt
在有 3 个包的 1 核运行器上运行测试。输出来自 vgt[0] 。
现在,整个执行时间非常接近最长测试的持续时间。
很难给出适用于所有项目的 -parallel
和 -p
值。这很大程度上取决于你的测试类型以及它们的结构。默认值对于许多轻量级单元测试或有效使用多核的 CPU 密集型测试来说都很好。
找到正确的 -parallel
标志的最好方法是尝试不同的值。 vgt
[0] 可能有助于理解不同的值如何影响测试执行。
子测试和测试表的并行性
当你需要测试一个函数的多个输入参数时,在 Go 中使用测试表非常有用。另一方面,它们也有一些危险:创建测试表有时可能比仅仅复制测试主体多次更复杂。
如果我们忘记添加 t.Parallel()
,使用测试表也会大大影响我们测试的性能。 这在有很多慢速测试用例的测试表中特别明显。即使一次使用测试表也可能使我们的测试明显变慢。
func TestApi_with_test_table(t *testing.T) { t.Parallel() testCases := []struct { Name string API string }{ {Name: "1", API: "/api/1"}, {Name: "2", API: "/api/2"}, {Name: "3", API: "/api/3"}, {Name: "4", API: "/api/4"}, {Name: "5", API: "/api/5"}, {Name: "6", API: "/api/6"}, {Name: "7", API: "/api/7"}, {Name: "8", API: "/api/8"}, {Name: "9", API: "/api/9"}, {Name: "10", API: "/api/9"}, {Name: "11", API: "/api/1"}, {Name: "12", API: "/api/2"}, {Name: "13", API: "/api/3"}, {Name: "14", API: "/api/4"}, {Name: "15", API: "/api/5"}, {Name: "16", API: "/api/6"}, {Name: "17", API: "/api/7"}, {Name: "18", API: "/api/8"}, {Name: "19", API: "/api/9"}, } for i := range testCases { t.Run(tc.Name, func(t *testing.T) { t.Parallel() simulateSlowCall(1 * time.Second) }) } }
不使用 t.Parallel() 的测试表。输出来自 vgt[0] 。
解决方案很简单:在测试表中添加 t.Parallel()
。但很容易忘记这一点。我们在使用测试表时可以小心。但在现实世界中,当你赶时间时,小心并不总是有用。我们需要一个自动化的方法来确保不会遗漏 t.Parallel()
。
检查是否使用了 t.Parallel()
的 Linting
在大多数项目中,我们使用 golangci-lint
[6] 。它允许你配置多个 linter 并为整个项目设置它们。
我们可以根据文件名配置应该启用哪些 linter。一个示例配置将确保所有以 _api_test.go
或 _integ_test.go
结尾的文件中的测试都使用 t.Parallel()
。不幸的是,作为缺点,它需要保持测试文件命名的约定。
或者,你可以按类型将测试分组到多个包中。因此,一个包可以包含 API 测试,另一个包可以包含集成测试。
如前所述,对所有类型的测试使用 t.Parallel()
不仅毫无意义,而且会更慢。 避免要求所有类型的测试都使用 t.Parallel()
。
这是一个 .golangci.yml
配置示例:
run: timeout: 5m linters: enable: # ... - paralleltest issues: exclude-rules: # ... - path-except: _api_test\.go|_integ_test\.go linters: - paralleltest
使用这个配置,我们可以运行 linter:
$ golangci-lint run package_1/some_api_test.go:9:1: Function TestApi_with_test_table missing the call to method parallel (paralleltest) func TestApi_with_test_table(t *testing.T) { ^
你可以在 golangci-lint 文档[7] 中找到配置参考。
并非所有测试都能始终并行运行。在这种情况下,你可以使用 //nolint:paralleltest
为特定测试禁用检查器。
//nolint:paralleltest func SomeTest(t *testing.T) { //nolint:paralleltest t.Run("some_sub_test", func(t *testing.T) { }) }
并行性特点:使用 t.Run()
对测试进行分组会影响性能吗?
我经常在许多项目中看到使用 t.Run()
对测试进行分组的惯例。你是否想过这会以任何方式影响性能?
为了验证这个假设,我编写了50个这样的测试:
func TestApi1(t *testing.T) { t.Parallel() t.Run("1", func(t *testing.T) { t.Run("1", func(t *testing.T) { t.Run("1", func(t *testing.T) { simulateSlowCall(1 * time.Second) }) }) }) t.Run("2", func(t *testing.T) { t.Run("2", func(t *testing.T) { t.Run("2", func(t *testing.T) { simulateSlowCall(1 * time.Second) }) }) }) } func simulateSlowCall(sleepTime time.Duration) { // for more reliable results I'm using constant time here time.Sleep(sleepTime) }
作为对比,我还编写了50个不使用 t.Run()
进行子测试的测试:
func TestApi1(t *testing.T) { t.Parallel() simulateSlowCall(1 * time.Second) simulateSlowCall(1 * time.Second) } func simulateSlowCall(sleepTime time.Duration) { // for more reliable results I'm using constant time here time.Sleep(sleepTime) }
这是否会通过影响并行性来影响测试性能?让我们看看 vgt
会显示什么。
不使用 r.Run() 进行子测试的测试。输出来自 vgt[0] 。
使用 r.Run() 进行子测试的测试。输出来自 vgt[0] 。
尽管图表看起来有点丑,但分组和非分组测试的执行时间是相同的。换句话说,使用 t.Run()
对测试进行分组不会影响性能。
总结
对于高效开发来说,拥有快速可靠的测试至关重要。但多年来,我了解到现实并不总是那么简单。总是有更多的工作要做,而测试并不是我们产品的用户或老板直接看到的东西。 另一方面,对测试的小投资可以在未来节省大量时间。可靠且快速的测试是项目投资回报率(ROI)最好的投资之一。
了解一些策略来说服你的团队领导或经理找时间改进测试是很有用的。用你的经理或老板使用的术语来思考很有帮助。你应该考虑他们关心的好处。 减少交付时间 - 你甚至可以计算整个团队每月因等待测试或重试测试而浪费的分钟数。将此乘以平均开发人员的薪资可能会很有用。追踪因测试太不稳定以至于没人注意到而发布到生产环境的 bug 也很有帮助。
还有更多方法可以让你的测试更有用。我还观察到人们经常在命名不同类型的测试时遇到困难 - 我们也会给你一些提示。如果你对更多关于测试的文章感兴趣,请查看:
- Go 中高质量数据库集成测试的4个实用原则[8]
- 微服务测试架构[9]
** vgt
[0] 对你有用吗?别忘了在 GitHub 上给它一个星星,并在社交媒体上与你的朋友分享!**
参考链接
vgt
: https://github.com/roblaszczak/vgt*testing.T
: https://pkg.go.dev/testing#Tt.Parallel()
: https://pkg.go.dev/testing#T.Paralleltesting
: https://cs.opensource.google/go/go/+/refs/tags/go1.23.1:src/testing/testing.go;l=1800- Go 文档说: https://pkg.go.dev/cmd/go/internal/test
golangci-lint
: https://golangci-lint.run/- golangci-lint 文档: https://golangci-lint.run/usage/configuration/
- Go 中高质量数据库集成测试的4个实用原则: https://threedots.tech/post/database-integration-testing/
- 微服务测试架构: https://threedots.tech/post/microservices-test-architecture/