Go 单元测试从零到一:表格驱动、基准测试与 Mock 实战
- Go 单元测试从零到一:表格驱动、基准测试与 Mock 实战
- 写在前面
- 一、Go 单元测试
- 1. 什么是单元测试?
- 2. 测试文件与测试函数命名规则
- 二、testing.T 常用方法
- 1. t.Errorf()
- 2. t.Fatalf()
- 3. t.Logf()
- 三、运行 Go 测试
- 1. go test
- 2. go test -v
- 3. go test ./...
- 4. go test -run
- 四、一个完整的单元测试示例
- cal.go
- cal_test.go
- 几个需要记住的规则
- 五、表格驱动测试
- 1. 什么是表格驱动测试?
- 2. 完整示例
- 六、t.Run():子测试
- 1. t.Run() 是什么?
- 2. 表格驱动测试 + t.Run()
- 3. t.Run() 与 Fatalf 的关系
- 七、基准测试(Benchmark)
- 1. 基准测试的命名规则
- 2. b.N 是什么?
- 3. 运行基准测试
- 4. ns/op 是什么?
- 八、基准测试中的内存指标
- 九、Mock 测试
- 1. 什么是 Mock?
- 2. Mock 是一种思想,而不是测试函数类型
- 十、接口 + 依赖注入 + Mock
- 十一、完整 Mock 测试示例
- 1. user.go
- 2. user_test.go
- 十二、Mock 测试的实践原则
- 1. 优先 Mock 接口,而不是具体实现
- 2. 不要为了 Mock 而 Mock
- 3. Mock 应该关注行为,而不是实现细节
- 4. 不要只测试正常情况
- 十三、Go 测试知识体系总结
- 十四、总结
写在前面
在 Go 语言中,测试是开发流程中非常重要的一环。Go 标准库自带testing包,不需要额外引入测试框架,就可以完成单元测试、表格驱动测试、基准测试等常见工作。
随着项目规模不断扩大,仅仅通过手动运行程序来判断代码是否正确,已经很难满足实际开发需求。一个函数可能需要覆盖正常情况、异常情况、边界情况;修改一处代码后,也需要快速确认是否影响了原有功能。
因此,掌握 Go 的测试工具和测试思想非常重要。
本文从最基础的单元测试开始,逐步介绍:
- 单元测试的基本规则
testing.T常用方法go test常用命令- 表格驱动测试
t.Run()子测试- 基准测试与性能指标
- Mock 测试
- 接口、依赖注入与 Mock 的关系
- Go 测试中的一些实践建议
一、Go 单元测试
1. 什么是单元测试?
单元测试(Unit Test)是针对程序中一个相对独立的功能单元进行验证。
例如:
funcAdd(a,bint)int{returna+b}我们可以编写测试验证:
输入:1、3 ↓ 调用 Add ↓ 实际结果:4 ↓ 与期望结果 4 比较 ↓ 一致 → 测试通过 不一致 → 测试失败单元测试最核心的目的就是:
验证代码的实际行为是否符合预期。
2. 测试文件与测试函数命名规则
Go 的测试工具会按照约定自动识别测试代码,因此需要遵循固定的命名规则。
| 规则 | 说明 |
|---|---|
| 测试文件名 | 必须以_test.go结尾,例如cal_test.go |
| 测试函数名 | 普通测试必须以Test开头,后面的名称首字母大写,例如TestAdd |
| 测试函数参数 | 必须接收*testing.T |
| 测试文件位置 | 通常与被测试代码放在同一个目录 |
例如:
cal/ ├── cal.go ├── cal_test.go └── go.mod需要注意的是,测试代码既可以使用:
packagecal也可以使用:
packagecal_test两者存在一定区别:
package cal:可以直接测试包内部的未导出标识符,例如小写函数addpackage cal_test:从外部包的角度测试,只能访问导出的标识符,例如Add
因此,如果测试的是:
funcadd(nint)int并且测试文件使用同一个package cal,就可以直接调用它。
二、testing.T 常用方法
普通单元测试使用:
*testing.T作为测试上下文。
通过t可以报告错误、输出日志以及创建子测试。
最常见的方法有三个:
| 方法 | 作用 |
|---|---|
t.Errorf(...) | 标记当前测试失败,输出错误信息,但继续执行 |
t.Fatalf(...) | 标记当前测试失败,输出错误信息,并立即终止当前测试 |
t.Logf(...) | 输出测试日志,不会影响测试结果 |
1. t.Errorf()
Errorf表示测试已经发现错误,但是不会立即停止当前测试函数。
funcTestAdd(t*testing.T){got:=Add(1,2)ifgot!=3{t.Errorf("期望值=%v,实际值=%v",3,got)}t.Logf("测试继续执行")}因此可以简单理解为:
Errorf → 发现错误 → 记录失败 → 继续执行如果一个测试中存在多个相互独立的检查,希望发现错误后继续执行后续检查,可以考虑使用Errorf。
2. t.Fatalf()
Fatalf同样会标记测试失败,但会立即终止当前测试函数。
funcTestAdd(t*testing.T){got:=Add(1,2)ifgot!=3{t.Fatalf("期望值=%v,实际值=%v",3,got)}t.Logf("测试成功")}如果got != 3,那么后面的代码不会继续执行。
可以记成:
Fatalf → 发现错误 → 记录失败 → 立即结束当前测试3. t.Logf()
Logf用来输出测试日志:
t.Logf("实际结果:%v",got)它不会影响测试结果。
需要注意的是,成功测试中的日志默认不会直接显示。想查看详细日志,可以使用:
gotest-v三、运行 Go 测试
如果项目使用 Go Module,首先可以在项目目录初始化模块:
go mod init example.com/cal之后就可以使用go test运行测试。
go mod init的作用是初始化 Go Module;它并不是“单元测试本身的命令”,而是现代 Go 项目进行模块化管理时的初始化步骤。
1. go test
gotest运行当前包中的测试。
测试通过时,会看到类似:
ok example.com/cal如果测试失败,则会输出失败信息。
2. go test -v
gotest-v-v表示输出更加详细的测试信息。
例如:
=== RUN TestAdd cal_test.go:15: 执行成功 --- PASS: TestAdd (0.00s) PASS因此:
go test → 快速查看测试结果 go test -v → 查看详细测试过程和日志3. go test ./…
gotest./...递归运行当前模块下所有包的测试。
例如项目结构:
project/ ├── go.mod ├── user/ │ ├── user.go │ └── user_test.go ├── order/ │ ├── order.go │ └── order_test.go └── product/ ├── product.go └── product_test.go执行:
gotest./...就可以统一运行这些包中的测试。
4. go test -run
如果只想运行某个测试,可以使用:
gotest-runTestAdd-run接收一个正则表达式,用于匹配测试名称。
例如:
gotest-run"Add$"可以匹配以Add结尾的测试名称。
因此,-run不只是“精确运行某个测试”,它本质上是:
根据正则表达式筛选需要运行的测试。
四、一个完整的单元测试示例
假设现在有一个计算1 + 2 + ... + n的函数。
cal.go
packagecalfuncadd(nint)int{res:=0fori:=1;i<=n;i++{res+=i}returnres}cal_test.go
packagecalimport"testing"funcTestAdd(t*testing.T){got:=add(10)ifgot!=55{t.Fatalf("执行错误!期望值=%v,实际值=%v",55,got)}t.Logf("执行成功")}因为:
1 + 2 + 3 + ... + 10 = 55所以测试应该通过。
运行:
gotest-v可以看到测试的详细执行结果。
几个需要记住的规则
- 测试文件必须以
_test.go结尾。 - 普通测试函数必须以
Test开头,并且后面的名称首字母大写。 - 普通测试函数接收
*testing.T。 - 一个测试文件中可以定义多个测试函数。
PASS表示测试通过,FAIL表示测试失败。
五、表格驱动测试
当一个函数需要测试很多组输入时,如果每一组都手动编写判断逻辑,代码很容易出现大量重复。
例如:
funcTestAdd(t*testing.T){ifAdd(1,3)!=4{t.Fatal("测试失败")}ifAdd(-2,-3)!=-5{t.Fatal("测试失败")}ifAdd(0,0)!=0{t.Fatal("测试失败")}}可以发现,真正变化的只有:
输入参数 期望结果 测试名称而测试逻辑基本完全一样。
这时候就可以使用:
表格驱动测试(Table-Driven Test)
1. 什么是表格驱动测试?
表格驱动测试的核心思想是:
把测试数据集中放到一个表格中,然后使用统一的测试逻辑遍历执行。
在 Go 中,这个“表格”通常就是一个结构体切片:
cases:=[]struct{namestringaintbintwantint}{{"正数相加",1,3,4},{"负数相加",-2,-3,-5},{"零值相加",0,0,0},}每一个结构体就是一组测试用例。
2. 完整示例
被测试函数:
packagecalfuncAdd(a,bint)int{returna+b}测试代码:
packagecalimport"testing"funcTestAdd(t*testing.T){cases:=[]struct{namestringaintbintwantint}{{"正数相加",1,3,4},{"负数相加",-2,-3,-5},{"零值相加",0,0,0},}for_,tt:=rangecases{got:=Add(tt.a,tt.b)ifgot!=tt.want{t.Fatalf("执行错误!期望值=%v,实际值=%v",tt.want,got,)}t.Logf("执行成功!")}}这种写法最大的优势是:
测试数据与测试逻辑分离。
以后如果需要增加测试场景,只需要增加一条数据:
{"大数相加",100,200,300},而不需要重新编写测试逻辑。
六、t.Run():子测试
前面的表格驱动测试还有一个问题。
如果直接在循环中使用:
t.Fatalf(...)那么某一个测试用例失败后,会直接终止整个TestAdd函数。
例如:
正数相加 → 通过 负数相加 → 失败 零值相加 → 不会继续执行这会影响我们一次性观察所有测试用例的结果。
这时候可以使用:
t.Run()1. t.Run() 是什么?
t.Run()可以创建一个独立的子测试:
t.Run("测试名称",func(t*testing.T){// 子测试代码})它可以让每一组测试拥有独立的:
- 名称
- 测试结果
- 测试日志
- 失败状态
2. 表格驱动测试 + t.Run()
funcTestAdd(t*testing.T){cases:=[]struct{namestringaintbintwantint}{{"正数相加",1,3,4},{"负数相加",-2,-3,-5},{"零值相加",0,0,0},}for_,tt:=rangecases{t.Run(tt.name,func(t*testing.T){got:=Add(tt.a,tt.b)ifgot!=tt.want{t.Fatalf("执行错误!期望值=%v,实际值=%v",tt.want,got,)}t.Logf("执行成功!")})}}执行后,可以看到类似:
=== RUN TestAdd === RUN TestAdd/正数相加 === RUN TestAdd/负数相加 === RUN TestAdd/零值相加 --- PASS: TestAdd/正数相加 --- PASS: TestAdd/负数相加 --- PASS: TestAdd/零值相加 --- PASS: TestAdd PASS如果其中一个子测试失败,也可以直接定位到具体的测试名称。
3. t.Run() 与 Fatalf 的关系
这里非常容易混淆。
如果直接写:
for_,tt:=rangecases{ifgot!=tt.want{t.Fatalf("测试失败")}}Fatalf会终止当前的TestAdd,后面的用例不会继续执行。
而使用:
for_,tt:=rangecases{t.Run(tt.name,func(t*testing.T){ifgot!=tt.want{t.Fatalf("测试失败")}})}此时Fatalf终止的是:
当前子测试
而不是整个父测试函数。
因此,其他子测试仍然可以继续执行。
这也是表格驱动测试中经常将t.Run()和t.Fatalf()搭配使用的原因。
七、基准测试(Benchmark)
单元测试主要回答:
代码执行结果是否正确?
而基准测试主要回答:
代码运行得有多快?会产生多少内存分配?
Go 标准库提供了专门的基准测试机制。
1. 基准测试的命名规则
基准测试同样写在:
_test.go文件中。
但函数名必须以:
Benchmark开头,并且参数为:
*testing.B例如:
funcBenchmarkAdd(b*testing.B){}普通单元测试和基准测试可以这样对比:
| 类型 | 函数命名 | 参数 |
|---|---|---|
| 单元测试 | TestXxx | *testing.T |
| 基准测试 | BenchmarkXxx | *testing.B |
2. b.N 是什么?
基准测试中最重要的概念之一就是:
b.N例如:
funcBenchmarkAdd(b*testing.B){fori:=0;i<b.N;i++{Add(2,3)}}这里并没有规定固定循环次数。
b.N会由 Go 的基准测试框架自动调整。
简单理解:
第一次测试 ↓ Go 设置一个 N ↓ 执行 N 次 ↓ 根据测试结果调整 N ↓ 再次执行 ↓ 最终得到稳定的性能数据因此,基准测试一般使用:
fori:=0;i<b.N;i++{// 被测代码}3. 运行基准测试
使用:
gotest-bench=.例如可能得到:
BenchmarkAdd-8 1000000000 0.3 ns/op其中:
BenchmarkAdd表示基准测试名称。
1000000000表示实际执行次数。
0.3 ns/op表示每次操作平均耗时约0.3纳秒。
4. ns/op 是什么?
ns/op的含义是:
nanoseconds per operation,每次操作平均消耗多少纳秒。
例如:
10 ns/op表示每次操作平均耗时10纳秒。
一般来说,在测试条件一致的情况下:
ns/op 越小 → 执行速度越快例如:
BenchmarkA 10 ns/op BenchmarkB 20 ns/op说明 A 的单次操作平均耗时更低。
八、基准测试中的内存指标
除了执行速度,基准测试还可以查看内存分配情况。
运行:
gotest-bench=.-benchmem例如:
BenchmarkAdd-8 1000000000 0.3 ns/op 0 B/op 0 allocs/op这里出现了三个重要指标:
| 指标 | 含义 |
|---|---|
ns/op | 每次操作平均耗时 |
B/op | 每次操作平均分配多少字节 |
allocs/op | 每次操作平均发生多少次内存分配 |
因此可以简单记忆:
ns/op → 看速度 B/op → 看内存分配量 allocs/op → 看内存分配次数例如:
100 B/op表示每次操作平均分配100字节。
而:
2 allocs/op表示每次操作平均发生2次内存分配。
九、Mock 测试
前面的单元测试和基准测试,主要针对代码本身。
但在真实的 Go 后端项目中,一个业务函数往往会依赖其他组件:
UserService ↓ UserRepository ↓ MySQL例如:
func(s UserService)GetUserName(idint)string{// 查询数据库}如果直接测试这个函数,就需要真的连接数据库。
这样测试会产生很多问题:
- 需要启动数据库
- 需要准备测试数据
- 测试速度变慢
- 数据库连接可能失败
- 测试结果依赖外部环境
- 测试数据可能污染真实环境
但是我们真正想测试的是:
UserService 的业务逻辑是否正确。
这时候就可以使用 Mock。
1. 什么是 Mock?
Mock 可以理解为:
使用一个假的实现替代真实依赖,让测试只关注当前被测代码。
原本:
UserService ↓ UserRepository ↓ MySQL测试时:
UserService ↓ MockUserRepository ↓ 返回测试数据这样就不需要连接真实数据库。
因此 Mock 的核心目的可以概括为:
隔离依赖,控制依赖行为。
2. Mock 是一种思想,而不是测试函数类型
Mock 并不是 Go 中特殊的测试函数。
也就是说,没有:
MockXxx()这样的固定测试函数规则。
Mock 本质上是一种:
替换真实依赖的测试思想。
因此 Mock 测试通常还是普通的:
funcTestXxx(t*testing.T)然后在测试中使用假的依赖实现。
十、接口 + 依赖注入 + Mock
Go 中非常常见的一种 Mock 方式就是:
接口 ↓ 依赖注入 ↓ Mock 实现例如定义一个接口:
typeUserRepositoryinterface{GetUser(idint)string}然后UserService不直接依赖具体数据库实现,而是依赖接口:
typeUserServicestruct{repo UserRepository}func(u UserService)GetUserName(idint)string{returnu.repo.GetUser(id)}这样UserService只需要知道:
repo 能不能调用 GetUser?而不需要关心:
到底是 MySQL? Redis? Mock? 其他实现?十一、完整 Mock 测试示例
1. user.go
typeUserRepositoryinterface{GetUser(idint)string}typeUserServicestruct{repo UserRepository}func(u UserService)GetUserName(idint)string{returnu.repo.GetUser(id)}2. user_test.go
定义一个假的 Repository:
typeMockUserRepositorystruct{}func(m MockUserRepository)GetUser(idint)string{return"零零"}然后注入:
funcTestGetUserName(t*testing.T){mockRepo:=MockUserRepository{}service:=UserService{repo:mockRepo,}result:=service.GetUserName(1)ifresult!="零零"{t.Fatalf("预期:零零,实际:%v",result)}}整个测试过程可以理解为:
创建 MockUserRepository ↓ 注入 UserService ↓ 调用 GetUserName(1) ↓ Mock 返回 "零零" ↓ 判断结果整个过程中没有连接数据库。
这就是最简单的 Mock 测试。
十二、Mock 测试的实践原则
1. 优先 Mock 接口,而不是具体实现
推荐:
业务代码 ↓ 接口 ↓ 真实实现 / Mock 实现而不是让业务代码直接依赖:
具体数据库实现接口能够降低耦合,也方便在测试时替换依赖。
2. 不要为了 Mock 而 Mock
不是所有代码都需要 Mock。
例如:
funcAdd(a,bint)int{returna+b}这种纯函数没有外部依赖,就没有必要 Mock。
Mock 更适合:
- 数据库
- 网络请求
- 第三方 API
- 消息队列
- 文件系统
- 外部服务
- 成本较高或不稳定的依赖
简单来说:
需要隔离的外部依赖才值得 Mock。
3. Mock 应该关注行为,而不是实现细节
测试应该关注:
输入 ↓ 行为 ↓ 输出而不是过度关注:
内部到底调用了几个函数? 内部变量叫什么? 具体用了哪种实现?如果测试过度依赖实现细节,那么以后即使功能没有改变,只是重构了内部代码,也可能导致大量测试失败。
好的测试应该尽量验证:
代码对外表现出来的行为。
4. 不要只测试正常情况
真实项目中,异常情况往往比正常情况更容易出问题。
因此除了:
正常返回还应该测试:
依赖返回错误 依赖超时 数据不存在 返回空数据 参数非法 边界值例如数据库查询可能出现:
查询成功 查询失败 用户不存在 数据库连接失败Mock 的一个重要价值就是:
可以人为控制依赖的返回结果,从而方便测试各种异常场景。
十三、Go 测试知识体系总结
到这里,可以把本文涉及的内容整理成下面的体系:
Go Testing │ ├── 单元测试 │ ├── _test.go │ ├── TestXxx │ ├── *testing.T │ ├── t.Errorf │ ├── t.Fatalf │ └── t.Logf │ ├── 测试命令 │ ├── go test │ ├── go test -v │ ├── go test ./... │ └── go test -run │ ├── 表格驱动测试 │ ├── 结构体切片 │ ├── 测试数据 │ ├── range │ └── t.Run │ ├── 基准测试 │ ├── BenchmarkXxx │ ├── *testing.B │ ├── b.N │ ├── ns/op │ ├── B/op │ └── allocs/op │ └── Mock 测试 ├── 接口 ├── 依赖注入 ├── Mock 实现 ├── 隔离外部依赖 └── 控制依赖行为十四、总结
Go 的测试工具虽然简单,但已经能够覆盖日常开发中绝大部分测试需求。
单元测试解决的是:
我的代码功能是否正确?
通过:
funcTestXxx(t*testing.T)可以对一个独立功能进行验证。
当测试场景越来越多时,可以使用表格驱动测试,将大量测试数据集中管理,再配合t.Run()把每组数据拆分成独立的子测试,提高测试代码的可读性和维护性。
如果需要关注代码的执行效率,可以使用基准测试:
funcBenchmarkXxx(b*testing.B)通过b.N自动执行大量测试,并使用:
ns/op B/op allocs/op分析代码的运行速度和内存分配情况。
而在 Go 后端开发中,如果业务代码依赖数据库、网络、第三方 API 等外部组件,就可以使用Mock隔离这些依赖,让测试更加快速、稳定,也更容易控制测试场景。
最终可以用一句话概括:
单元测试验证功能正确性,表格驱动测试提高测试效率,
t.Run()管理独立用例,基准测试衡量性能,Mock 则负责隔离外部依赖。
如果继续深入 Go 测试体系,还可以进一步学习:
go test -cover:测试覆盖率t.Helper():提高测试辅助函数的报错定位准确性t.Parallel():并行测试- Benchmark 的
ResetTimer()、StopTimer()和StartTimer() - HTTP Handler 测试
httptest包- 测试中的依赖注入
- 第三方 Mock 框架与代码生成