功能测试与非功能测试

在第 1 章《掌握测试驱动开发》中,我们简要介绍了非功能性测试的主题。到目前为止,我们将这个重要的测试类型搁置了一旁,专注于验证各种功能性方面,并探索了流行的测试库,如 testifyginkgoGoDog。现在,让我们来探讨如何实现一些最重要的非功能性测试。

图8.1 展示了主要的非功能性测试类型:

这些测试类型分为性能测试和可用性测试,验证我们系统的以下几个方面:

  1. 负载测试:模拟用户对系统的需求。这些测试模拟预期的需求和过载条件,以识别瓶颈或性能问题。

  2. 压力测试:模拟在极端条件下对系统的用户需求。这些测试用于确定系统的可扩展性极限,并验证当组件超载时系统如何优雅地处理错误。

  3. 数据量测试:模拟大量数据进入系统。这类似于压力测试,但每个测试涉及相对较大的数据量,而不是多个测试模拟用户需求时的小数据量。这些测试用于识别系统能够处理的最大数据量,特别适用于有数据库/持久存储解决方案的服务。

  4. 可扩展性测试:验证系统在负载突然增加时能否扩展其组件。负载可以逐步增加,也可以突然增加,这被称为 峰值测试

  5. 故障转移测试:验证系统在发生故障后能否恢复。此类负面测试非常有用,模拟系统在发生事故后的恢复速度。

  6. 配置测试:验证系统在不同设置下的行为。这些设置可以是用户控制的设置或系统设置。系统的设置可以改变系统的预期行为,以及其性能。

  7. 可用性测试:验证面向用户的功能是否易于使用。这类测试的重点根据系统暴露的功能有所不同,但通常涵盖以下内容:

    • 系统对新用户来说是否直观易用

    • 用户执行任务的难易程度

    • 错误信息是否表述清晰,并引导用户

  8. 安全性测试:验证开发过程中是否遵循了安全实践。被测试的系统应具有正确的身份验证、授权和数据完整性功能。

如我们所见,非功能性测试对于确保我们的系统在各种条件下正常运行至关重要。没有涵盖这些重要类型的测试,任何测试策略都不算完整。

非功能性测试验证系统的关键方面

这些测试验证了系统在测试下的性能和可用性,包括系统的扩展性和恢复能力。这些类型的测试可能由不同的开发团队执行,因为它们可能需要工程团队之外的技能来实施。

Go中的性能测试

虽然我们已经确定非功能性测试涵盖了重要的方面,但当从单体应用迁移到微服务架构时,性能测试变得更加重要。在微服务的世界中,用户旅程是由独立的系统组件处理的,这可能导致对系统行为的整体视图不够统一。

图8.2 展示了性能测试所回答的关键问题:

image 2025 01 04 18 46 10 582
Figure 1. Figure 8.2 – Key questions that performance testing answers

性能测试回答的两个重要问题涉及系统的可用性和可扩展性。让我们看看每个问题的含义。

系统是否可用?

可用性不仅仅是实现正确的功能,因为一个运行缓慢的系统最终会对用户满意度产生负面影响。性能测试对于评估以下方面非常有用:

  • 稳定性:系统不应出现间歇性的故障,导致重试和负面的用户体验。

  • 速度:用户请求应保持在根据业务需求设定的可接受水平内,或者系统应适当扩展。

  • 错误处理:系统应优雅地处理错误,不应发生突如其来的崩溃,并且应返回精心设计的错误信息,涵盖多种场景。

  • 用户负载:系统应能够处理预期的用户负载,而不会引发意外的 CPU 或用户内存峰值。

系统是否可扩展?

随着时间的推移,业务和系统需求不断发展。一个可扩展的系统应能够根据未来的业务需求进行增长。性能测试对于评估以下方面非常有用:

  • 瓶颈:通过监控各种指标,我们可以识别系统中哪些服务不可扩展,需要重构。

  • 个别部分:了解每个微服务的预期响应时间,以及整个系统的估算响应时间是非常重要的。这可以帮助我们映射系统中每个用户操作的成本。

  • 增长预期:性能测试可以帮助我们确定系统在当前形式下能够承受多少用户和数据增长。

当性能测试正确使用时,它将确保每个微服务能够处理当前的系统负载,并且它们能够共同工作,正确地服务于用户旅程。

“小而频繁” 的方法

性能测试通常作为代码构建管道的一部分进行添加,这样开发团队可以在每次提交时获得即时的性能反馈。类似于重构,性能改进最好是 “小而频繁” 地进行。通过在每次提交时监控性能,开发团队能够更容易地看到任何趋势并快速修复新的问题。

性能测试的核心是量化并比较系统及其微服务的行为。那么,我们如何实现这一量化呢?这通常是通过收集一些重要的指标来完成的:

  • 响应时间:从用户发出请求到系统响应并返回给用户的时间。通常,响应时间的平均值和峰值会被测量,以显示最坏情况和平均情况。

  • 错误率:系统处理的总请求数中,错误响应占的百分比。在 RESTful API 中,错误响应通常通过 HTTP 状态码来识别。

  • CPU 和内存使用率:微服务在其主机上使用的 CPU 和内存百分比。这些指标将显示系统是否正确扩展。

  • 并发用户数:同时请求给定资源的用户数。这可以帮助识别某个特定端点的用户路径是否有峰值。

  • 数据吞吐量:系统处理的数据量。这可以指示用户请求是否随着时间的推移而增加,或者是否有大量文件流入系统并影响性能。

在编写性能测试之前,被测系统应具有这些指标的监控和警报功能。此外,我们应根据系统需求确定性能测试的失败标准。

虽然您应始终与关键利益相关者一起确定您的阈值值,但我们可以根据经验和行业实践做出一些一般性的建议:

  • 平均响应时间通常应低于 500 毫秒,峰值响应时间应低于 1 秒

  • 错误率通常应低于 5%

  • CPU 和内存使用率通常应保持在 70% 以下,以便系统能够处理可能出现的负载峰值

  • 并发用户数和数据吞吐量没有固定的失败阈值,但应监控峰值和异常情况

现在,我们了解了性能测试的重要性,以及如何量化和比较它们的结果,接下来可以将注意力转向其实现。我们可以使用 Go 的标准测试框架或流行的第三方库来实现这些性能测试。

实现性能测试

在第 2 章《单元测试基础》中,我们学习了如何使用 Go 的标准测试库编写和执行基准测试,基准测试是用于验证我们代码性能的特殊测试。我们还学习了如何从 Go 的测试运行器导出测试覆盖率指标。

我们可以使用基准测试为我们的端点编写性能测试。例如,我们可以轻松地为 BookSwap 应用程序的 GET / 根端点编写基准测试:

func BenchmarkGetIndex(b *testing.B) {
    endpoint := getTestEndpoint(b)
    for x := 0; x < b.N; x++ {
        bks, err := http.Get(*endpoint)
        assert.Nil(b, err)
        assert.NotNil(b, bks)
    }
}
go

我们根据预期的签名创建一个新的基准测试,接受一个 *testing.B 参数,并以 Benchmark 前缀命名。然后,我们使用标准的 http 库来调用定义的端点的 GET 操作,该端点由 getTestEndpoint 辅助函数返回。就像前几章一样,这个函数根据提供的环境变量构建端点。如果你想使用默认值,可以将 BOOKSWAP_BASE_URL 环境变量设置为 http://localhost,并将 BOOKSWAP_PORT 环境变量设置为 3000,然后在终端会话中运行。

我们将此测试保存在 chapter08/performance/books_index_test.go 文件中。编写了简单的测试后,我们需要确保 BookSwap 应用程序正在运行。可以使用 docker compose -f docker-compose.book-swap.chapter08.yml up --build 命令轻松启动它。如前所述,运行之前请记得设置 BOOKSWAP_PORT 环境变量。如果使用默认配置,则可以将其值设置为 3000

接下来,我们需要运行基准测试。go test 命令提供了对基准测试的支持,类似于我们在第 2 章《单元测试基础》中提取代码覆盖率详细信息的方式。runtime/pprof 包提供了以下预定义的分析选项:

  • cpu:显示程序使用 CPU 周期的地方

  • heap:显示程序进行内存分配的地方

  • threadcreate:显示程序创建新线程的地方

  • goroutine:显示程序所有 goroutine 的堆栈跟踪

  • block:显示 goroutine 在等待锁原语的地方

  • mutex:报告锁争用情况

我们将在第 9 章《并发代码测试挑战》中探讨线程、goroutine 和互斥锁的并发方面。现在,我们将专注于 CPU 分析。

我们使用两个分析选项运行新编写的基准测试,这将允许我们提取 CPU 配置文件:

$ go test -bench BenchmarkGetIndex -cpuprofile cpu-books.out ./chapter08/performance
bash

基准测试运行器输出了我们在介绍性章节中看到的相同结果:

pkg: github.com/PacktPublishing/Test-Driven-Development-in-Go/chapter08/performance
BenchmarkGetIndex-8 1556 796124 ns/op
PASS
ok github.com/PacktPublishing/Test-Driven-Development-in-Go/chapter08/performance 2.600s
bash

由于我们的索引端点非常简单,基准测试执行了 1,556 次,总运行时间为 2.6 秒。此命令运行基准测试,并指示测试运行器将 CPU 配置文件保存到 cpu-books.out 文件中,文件保存在当前运行目录中。测试运行的详细信息保存在名为 performance.test 的文件中,该文件以测试声明所在的包名命名。

我们可以使用 pprof 命令工具查看文件,pprof 工具与 Go 工具链一起安装:

$ go tool pprof performance.test cpu-books.out
bash

这将打开一个交互式命令行,允许我们查看测量的 CPU 时间。该命令会显示前五个分析结果的文本输出,而 web 命令将创建相同结果的可视化表示。运行 top5 命令时,我们看到基准测试的 CPU 配置文件的前五个结果:

(pprof) top5
Showing nodes accounting for 550ms, 80.88% of 680ms total
Showing top 5 nodes out of 91
flat  flat%  sum%   cum   cum%
180ms 26.47% 26.47% 180ms 26.47% runtime.pthread_cond_signal
120ms 17.65% 44.12% 120ms 17.65% runtime.kevent
100ms 14.71% 58.82% 100ms 14.71% runtime.cgocall
80ms  11.76% 70.59% 120ms 17.65% runtime.pthread_cond_wait
70ms  10.29% 80.88% 80ms 11.76% syscall.syscall
bash

这些顶级结果占用了超过 80% 的运行时间,但它们似乎只与基准测试本身的运行和调度有关。由于基准测试被调度并运行了成千上万次,我们可以预期测试运行器会使用相当多的 goroutine 和线程来执行测试。然而,这对了解我们的 BookSwap Web 应用程序的运行情况并不十分有用。由于 Web 应用程序是在与基准测试分开的另一个进程中运行的,我们不能通过基准测试对其进行分析。

为了获得 BookSwap 应用程序的 CPU 使用情况,我们需要将 pprof 工具集成到我们的 Web 应用程序中。这可以通过让 pprof 与我们在 handlers/config.go 中的其他处理程序一起注册来轻松实现:

func ConfigureServer(handler *Handler) *mux.Router {
    router := mux.NewRouter().StrictSlash(true)
    // other handler functions
    if os.Getenv("DEBUG") != "" {
        router.PathPrefix("/debug/pprof/").
            Handler(http.DefaultServeMux)
    }
    return router
}
go

现在,如果在应用程序启动时设置 DEBUG 环境变量,pprof 就能提供所有配置为 debug/pprof 前缀的路径。我们可以通过在 docker.env 文件中添加 DEBUG=true 来轻松设置它。然后,可以使用 docker compose -f docker-compose.book-swap.chapter08.yml up --build 命令以调试模式重新运行应用程序。这样,我们就可以在特定环境中选择性地暴露此端点。现在我们准备好对 Web 应用程序进行分析了。

我们重新运行基准测试,预计需要大约 3 秒钟来完成。然后,我们可以像导出基准测试配置文件结果一样,将分析结果下载到本地文件:

$ curl --output book-swap-app "http://localhost$BOOKSWAP_PORT/debug/pprof/profile?seconds=10"
bash

由于在本例中应用程序是在本地运行的,URL 为 localhost:$BOOKSWAP_PORT,但对于其他环境和配置,我们需要修改它。此命令会下载过去 10 秒的分析数据并保存到本地文件。然后,我们可以像之前一样查看导出的结果:

$ go tool pprof book-swap-app
bash

此命令会打开与之前相同的交互式界面,但现在我们可以选择通过 web 命令查看 CPU 的可视化表示。这将启动浏览器窗口,显示已分析的函数调用图。

图形可视化

Go 的分析工具 pprof 依赖于一个外部依赖项来进行图形可视化。这个依赖项叫做 graphviz,它不是用 Go 编写的,因此不会随 Go 工具链一起自动安装。你应该根据官方文档( https://graphviz.org/download/ )来为你的操作系统安装它。

图 8.3 展示了在基准测试索引端点时测量的 CPU 配置文件的可视化表示。

image 2025 01 04 18 49 23 301
Figure 2. Figure 8.3 – A visual representation of the BookSwap CPU profile

正如我们从 CPU 配置文件中看到的,BookSwap 应用程序将大部分资源用于处理 HTTP 连接和通过 GORM 库与数据库层交互。通过查看每个操作的百分比和对应的框的大小,我们可以清楚地看到资源的消耗情况。调用栈的可视化表示也为我们提供了哪些地方消耗了大量资源的良好指示。

在第 6 章《BookSwap Web 应用程序的端到端测试》中,我们探讨了 BookSwap 应用程序的数据库方面。如果我们想提高应用程序的性能,可以利用配置文件中展示的信息,识别调用栈中需要优化的区域。

尽管基准测试允许我们创建简单的测试并模拟各种负载测试场景,但在多个微服务中定义测试场景可能会显得非常冗长。为了简化性能测试,常用的两种开源库包括:

  • JMeterhttps://jmeter.apache.org/ )是由 Apache 维护的开源 Java 测试工具。测试计划通过简单的用户界面录制,从而消除了使用 Go 测试包编写模板代码的需要。可以配置不同类型的负载,JMeter 还具备在测试运行后生成结果图表和仪表板的能力。

  • K6https://k6.io/ )是由 Grafana 维护的开源 Go 项目。测试计划使用类似 JavaScript 的脚本语言编写,减少了编写测试场景时所需的代码量。K6 提供了不同类型的负载配置,并且能够将测试结果输出到仪表板中。

  • Gatlinghttps://gatling.io/open-source/ )是由 Gatling Corp 维护的开源 Scala 负载测试工具。与 K6 类似,测试是用一种领域特定语言(DSL)编写的,但它基于 Scala 语言。该工具提供负载测试并在仪表板上提供洞察。

无论选择哪种性能测试实现方案,你都可以对应用程序进行分析,并补充该工具所提供的数据和图表。由于我们在本书中使用了 Go 内建的基准测试功能来编写性能测试,因此不会进一步探讨这些第三方工具。

Go 的分析工具非常强大,拥有比我们在这里探讨的更多功能。你可以在官方文档中阅读更多关于 Go 诊断功能的信息( https://go.dev/doc/diagnostics )。

分析测试和应用程序

尽管我们没有直接使用运行基准测试时收集的分析信息,但分析测试可以是调查消耗资源或运行缓慢的测试的有用方法。因此,了解如何导出和读取分析信息对于开发和编写测试都是非常有用的。