
Go 单元测试从零到一表格驱动、基准测试与 Mock 实战Go 单元测试从零到一表格驱动、基准测试与 Mock 实战写在前面一、Go 单元测试1. 什么是单元测试2. 测试文件与测试函数命名规则二、testing.T 常用方法1. t.Errorf()2. t.Fatalf()3. t.Logf()三、运行 Go 测试1. go test2. go test -v3. go test ./...4. go test -run四、一个完整的单元测试示例cal.gocal_test.go几个需要记住的规则五、表格驱动测试1. 什么是表格驱动测试2. 完整示例六、t.Run()子测试1. t.Run() 是什么2. 表格驱动测试 t.Run()3. t.Run() 与 Fatalf 的关系七、基准测试Benchmark1. 基准测试的命名规则2. b.N 是什么3. 运行基准测试4. ns/op 是什么八、基准测试中的内存指标九、Mock 测试1. 什么是 Mock2. Mock 是一种思想而不是测试函数类型十、接口 依赖注入 Mock十一、完整 Mock 测试示例1. user.go2. user_test.go十二、Mock 测试的实践原则1. 优先 Mock 接口而不是具体实现2. 不要为了 Mock 而 Mock3. Mock 应该关注行为而不是实现细节4. 不要只测试正常情况十三、Go 测试知识体系总结十四、总结写在前面在 Go 语言中测试是开发流程中非常重要的一环。Go 标准库自带testing包不需要额外引入测试框架就可以完成单元测试、表格驱动测试、基准测试等常见工作。随着项目规模不断扩大仅仅通过手动运行程序来判断代码是否正确已经很难满足实际开发需求。一个函数可能需要覆盖正常情况、异常情况、边界情况修改一处代码后也需要快速确认是否影响了原有功能。因此掌握 Go 的测试工具和测试思想非常重要。本文从最基础的单元测试开始逐步介绍单元测试的基本规则testing.T常用方法go test常用命令表格驱动测试t.Run()子测试基准测试与性能指标Mock 测试接口、依赖注入与 Mock 的关系Go 测试中的一些实践建议一、Go 单元测试1. 什么是单元测试单元测试Unit Test是针对程序中一个相对独立的功能单元进行验证。例如funcAdd(a,bint)int{returnab}我们可以编写测试验证输入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 testgotest运行当前包中的测试。测试通过时会看到类似ok example.com/cal如果测试失败则会输出失败信息。2. go test -vgotest-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-runAdd$可以匹配以Add结尾的测试名称。因此-run不只是“精确运行某个测试”它本质上是根据正则表达式筛选需要运行的测试。四、一个完整的单元测试示例假设现在有一个计算1 2 ... n的函数。cal.gopackagecalfuncadd(nint)int{res:0fori:1;in;i{resi}returnres}cal_test.gopackagecalimporttestingfuncTestAdd(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 Test1. 什么是表格驱动测试表格驱动测试的核心思想是把测试数据集中放到一个表格中然后使用统一的测试逻辑遍历执行。在 Go 中这个“表格”通常就是一个结构体切片cases:[]struct{namestringaintbintwantint}{{正数相加,1,3,4},{负数相加,-2,-3,-5},{零值相加,0,0,0},}每一个结构体就是一组测试用例。2. 完整示例被测试函数packagecalfuncAdd(a,bint)int{returnab}测试代码packagecalimporttestingfuncTestAdd(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.B2. b.N 是什么基准测试中最重要的概念之一就是b.N例如funcBenchmarkAdd(b*testing.B){fori:0;ib.N;i{Add(2,3)}}这里并没有规定固定循环次数。b.N会由 Go 的基准测试框架自动调整。简单理解第一次测试 ↓ Go 设置一个 N ↓ 执行 N 次 ↓ 根据测试结果调整 N ↓ 再次执行 ↓ 最终得到稳定的性能数据因此基准测试一般使用fori:0;ib.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. 什么是 MockMock 可以理解为使用一个假的实现替代真实依赖让测试只关注当前被测代码。原本UserService ↓ UserRepository ↓ MySQL测试时UserService ↓ MockUserRepository ↓ 返回测试数据这样就不需要连接真实数据库。因此 Mock 的核心目的可以概括为隔离依赖控制依赖行为。2. Mock 是一种思想而不是测试函数类型Mock 并不是 Go 中特殊的测试函数。也就是说没有MockXxx()这样的固定测试函数规则。Mock 本质上是一种替换真实依赖的测试思想。因此 Mock 测试通常还是普通的funcTestXxx(t*testing.T)然后在测试中使用假的依赖实现。十、接口 依赖注入 MockGo 中非常常见的一种 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.gotypeUserRepositoryinterface{GetUser(idint)string}typeUserServicestruct{repo UserRepository}func(u UserService)GetUserName(idint)string{returnu.repo.GetUser(id)}2. user_test.go定义一个假的 RepositorytypeMockUserRepositorystruct{}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{returnab}这种纯函数没有外部依赖就没有必要 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 框架与代码生成