1、如何编写单元测试
在任何生产级别的项目开发中,单元测试都扮演着至关重要的角色。尽管许多初创项目在早期可能忽略了它,但随着项目逐渐成熟并成为核心业务,为其编写健壮的单元测试是保障代码质量和项目稳定性的必然选择。本文将带您快速掌握 Go 语言中单元测试的基本方法和核心概念。
1、核心命令:go test
Go 语言的测试工具链非常简洁,其核心是 go test
命令。这是一个依据特定约定来组织和驱动测试代码的程序。当你在一个包目录中执行 go test
时,它会自动寻找并执行所有符合测试规范的用例。
2、测试文件的约定
go test
命令的运行依赖于一套简单的文件和函数命名约定:
- 文件名约定:在包目录中,所有以
_test.go
为后缀的源文件都会被go test
命令识别为测试文件并执行。 - 构建时排除:您无需担心测试文件会增加最终可执行文件的大小。
go build
命令在编译和打包时会自动忽略这些_test.go
文件,确保它们只存在于测试环境中。
3、测试函数的类型
在 _test.go
文件中,我们主要会编写以下几种类型的测试函数,目前我们主要关注前两种:
- 功能测试 (Functional Tests):函数名以
Test
开头,例如TestMyFunction
。这是最常见的测试类型,用于验证代码功能的正确性。 - 性能测试 (Benchmark Tests):函数名以
Benchmark
开头,例如BenchmarkMyFunction
。用于衡量代码的性能和效率。 - 示例测试 (Example Tests):函数名以
Example
开头,用于提供可执行的示例代码,常用于文档生成。 - 模糊测试 (Fuzzing Tests):以
Fuzz
开头,是一种自动化的测试技术,用于发现边界条件下的潜在错误。
4、编写第一个单元测试
让我们通过一个具体的例子来演示如何编写和运行一个单元测试。
1. 准备被测试的代码
首先,我们创建一个 add.go
文件,并在其中定义一个简单的加法函数。
2. 创建测试文件
接下来,在与 add.go
相同的包(目录)下,我们创建测试文件 add_test.go
。将测试文件和源文件放在同一包内,可以方便地测试包内未导出的(小写字母开头的)函数和方法。
3. 编写测试函数
在 add_test.go
文件中,我们编写一个功能测试函数来验证 Add
函数的正确性。
测试函数的规范:
- 函数名必须以
Test
开头,后面通常跟上被测试的函数名,如TestAdd
。 - 参数必须是
t *testing.T
。*testing.T
类型提供了报告测试失败和记录日志等核心功能。
编写完成后,许多 IDE(如 GoLand)会自动在函数旁显示一个可运行的标记,这表明 IDE 已经识别出这是一个有效的测试用例。
2、管理和跳过耗时测试
当测试用例数量增多时,某些测试(如涉及网络请求或大量计算的测试)可能会运行得非常缓慢。为了在开发过程中获得快速反馈,我们常常希望可以跳过这些耗时的测试。
Go 语言为此提供了 -short
模式。
工作原理
- 在运行测试时,可以附加
-short
标志:go test -v -short
。 - 在测试函数内部,可以通过
testing.Short()
函数进行判断。如果-short
标志被设置,该函数返回true
。 - 使用
t.Skip()
方法来跳过当前测试。当t.Skip()
被调用时,该测试函数会立即终止,并被标记为“已跳过”(SKIPPED),而不会被标记为“失败”(FAILED)。
让我们添加一个模拟的耗时测试 TestLongRunningTask
。
.正常模式
执行 go test -v
,所有测试都会运行,包括耗时的测试。
可以看到,总耗时超过了2秒。
b. Short 模式
执行 go test -v -short
,耗时的测试将被跳过。
可以看到,TestLongRunningTask
被标记为 SKIP
,并且总耗时非常短。通过这种方式,我们可以灵活地控制运行哪些测试用例,从而提升开发效率。
3、表格驱动测试 (Table-Driven Tests)
当我们需要用多组不同的输入和期望输出来测试同一个函数时(例如,测试正常情况、边界情况、异常情况),为每一组数据编写一个独立的 Test
函数会非常繁琐且难以维护。
表格驱动测试模式优雅地解决了这个问题。其核心思想是将所有测试用例定义在一个“表格”(通常是一个结构体切片)中,然后在一个测试函数内遍历这个表格,执行每一个测试用例。
实现步骤
- 定义测试用例结构体:创建一个结构体,用于描述一个完整的测试用例,包含输入参数和期望的输出结果。
- 创建测试用例表格:声明一个该结构体的切片,并填充所有需要测试的数据。
- 遍历表格执行测试:在测试函数中,使用
for
循环遍历切片。对于每个测试用例,执行被测函数并断言结果。 - (推荐) 使用
t.Run
创建子测试:在循环中为每个测试用例创建一个子测试。这样做的好处是,所有测试用例都会被执行(即使中途有失败),并且测试结果会清晰地分组展示,便于定位问题。
代码示例
让我们用表格驱动模式来重构 TestAdd
。
当我们运行这个测试时,如果出现错误(例如,我们将第三个用例的期望值 expected
错写成 0
),会得到非常清晰的报告。
这个输出明确地告诉我们,只有名为 input:_-9,_8
的子测试失败了,极大地提高了调试效率。
4、性能测试 (Benchmark Tests)
除了功能正确性,代码的性能也是衡量质量的重要维度。对于一些位于核心路径、对性能有高要求的函数,我们需要进行性能测试。Go 语言内置了强大的性能测试框架。
性能测试的规范
- 函数命名:性能测试函数必须以
Benchmark
开头,例如BenchmarkAdd
。 - 函数签名:参数必须是
b *testing.B
。*testing.B
类型提供了控制计时器、设置迭代次数等能力。 - 核心循环:测试的主体逻辑必须放在一个
for
循环内,循环次数由b.N
决定:for i := 0; i < b.N; i++
。go test
命令会自动调整b.N
的值,反复运行代码直到获得稳定可靠的测量结果。
简单示例
实践:比较字符串拼接性能
字符串拼接是一个非常常见的操作,不同的实现方式性能差异巨大。下面我们通过性能测试来实际比较三种方法的优劣:fmt.Sprintf
、+
操作符和 strings.Builder
。
通过命令行运行测试 使用 go test
命令并附加 -bench
标志。.
作为参数表示运行当前包下所有的性能测试。
go test -bench=.
运行获取到的结果,具体数值取决于您的机器性能。
根据提供的基准测试结果,以下是各个字符串拼接方法的性能对比分析:
[BenchmarkStringSprintf](file:///Users/jie/Desktop/code/go/onego/xh02/add_test.go#L12-L20)(使用
fmt.Sprintf
拼接):- 执行次数:88 次
- 每次操作耗时:约 13,797,447 ns (13.8 ms)
- 分析:性能最差,因为
fmt.Sprintf
在每次调用时都需要进行格式解析和内存分配,效率较低。
[BenchmarkStringAdd](file:///Users/jie/Desktop/code/go/onego/xh02/add_test.go#L23-L31)(使用
+
操作符拼接):- 执行次数:99 次
- 每次操作耗时:约 11,765,825 ns (11.8 ms)
- 分析:比
fmt.Sprintf
快,但由于字符串是不可变类型,每次+
操作都会创建新字符串并复制内容,性能依然有限。
[BenchmarkStringBuilder](file:///Users/jie/Desktop/code/go/onego/xh02/add_test.go#L34-L43)(使用
strings.Builder
拼接):- 执行次数:9418 次
- 每次操作耗时:约 123,530 ns (0.12 ms)
- 分析:性能最优。
strings.Builder
是专为高效字符串拼接设计的类型,内部使用[]byte
缓冲区,避免了频繁的内存分配和复制。
总结:
strings.Builder
明显优于其他两种方式,尤其在大量字符串拼接操作中表现最佳。- 避免在循环中使用
fmt.Sprintf
或+
进行字符串拼接,除非对性能要求不高。 - 推荐在性能敏感场景下优先使用
strings.Builder
。