Go语言单元测试
Go语言单元测试
单元测试是保证项目质量、简化debug和验证代码功能的重要手段。在Go语言中,利用testing
包可以方便地编写和执行单元测试,利用mockgen
包可以模拟待测试函数的依赖对象。本教程将介绍如何在Go语言中写好单元测试,涵盖从基本测试到复杂场景的应用,并结合目前云游戏使用的主流框架kratos
进行最佳实践。
testing 基础教程
1. 编写基本的单元测试
在Go语言中,每个测试文件以 _test.go
结尾,放置于与源代码相同的目录下。例如,有文件 calc.go
,对应的测试文件应该命名为 calc_test.go
。
- 测试用例名称一般命名为
Test
加上待测试的方法名 - 测试用的参数有且只有一个,在这里是
t *testing.T
示例:测试 Add
和 Mul
函数
假设 calc.go
的内容如下:
1 | package main |
对应的 calc_test.go
可以写成:
1 | package main |
运行测试命令:
1 | go test -v |
2. 使用子测试 (Subtests)
子测试允许在一个测试函数中定义多个小测试,这是通过 t.Run
实现的。这种方式使得测试结果更加模块化。
示例:使用子测试对 Mul
函数进行测试
1 | func TestMul(t *testing.T) { |
对于多个子测试的场景,更推荐如下的写法(table-driven tests):
1 | // calc_test.go |
所有用例的数据组织在切片 cases
中,看起来就像一张表,借助循环创建子测试。这样写的好处有:
- 新增用例非常简单,只需给 cases 新增一条测试数据即可。
- 测试代码可读性好,直观地能够看到每个子测试的参数和期待的返回值。
- 用例失败时,报错信息的格式比较统一,测试报告易于阅读。
3. t.Error
还是 t.Fatal
t.Error/t.Errorf
和 t.Fatal/t.Fatalf
都是测试辅助函数,用于标记测试失败
它们主要区别在于:
- t.Error 遇错不停,还会继续执行其他的测试用例。通常用于表示某些预期之外的问题,但不会影响后续测试的执行。
- t.Fatal 遇错即停,通常用于表示遇到了无法恢复的错误,例如必要的资源不可用或关键操作失败。
4. TestMain(m)
TestMain
是 Go 语言测试包中的一个特殊函数,如果测试文件中包含函数 TestMain
,那么生成的测试将调用 TestMain(m),而不是直接运行测试。
- 需要调用
m.Run()
触发所有测试用例的执行,并使用os.Exit()
处理返回的状态码,如果不为0,说明有用例失败。 - 可以在调用
m.Run()
前后做一些额外的准备(setup)和回收(teardown)工作。
示例:使用 TestMain
测试需要前置操作的函数
1 | func setup() error { |
5. 网络测试
测试涉及到网络操作时,可以使用真实的TCP/HTTP连接,推荐使用 net/http/httptest
进行更加控制的测试。
示例:使用 httptest
测试HTTP处理函数
1 | func TestHelloHandler(t *testing.T) { |
6. 基准测试 (Benchmark Tests)
基准测试用于测量和评估代码段的性能。通过比较不同实现的性能指标,你可以优化你的代码。
- 在Go语言中,基准测试的函数名必须以
Benchmark
开头,并接受一个类型为*testing.B
的参数。
示例:基准测试 Add
函数
1 | func BenchmarkAdd(b *testing.B) { |
执行基准测试,使用 -bench
参数,-benchmem
表示显示内存分配信息:
1 | go test -benchmem -bench=. |
基准测试报告每一列值对应的含义如下:
1 | type BenchmarkResult struct { |
如果在运行前基准测试需要一些耗时的配置,则可以使用 b.ResetTimer()
先重置定时器,例如:
1 | func BenchmarkHello(b *testing.B) { |
7. 使用并发性能测试 (Parallel Tests)
基准测试提供并发性能测试,并发性能测试有助于理解代码在高并发条件下的表现。
示例:并发HTTP请求处理性能测试
假设我们有一个处理HTTP GET请求的函数,该函数返回一个简单的欢迎消息。我们将使用Go的net/http/httptest
库来模拟HTTP请求和响应。
1 | package main |
运行基准测试
执行上述基准测试时,使用以下命令行命令:
1 | go test -benchmem -bench=. |
输出解释
- 5000000 表示测试在配置的时间内执行了约 5000000 次迭代。
- 240 ns/op 表示每次操作平均耗时 240 纳秒。
- 32 B/op 表示每次操作平均分配了 32 字节的内存。
- 1 allocs/op 表示每次操作平均进行了 1 次内存分配。
mockgen 基础教程
在 Go 中编写单元测试时,经常需要模拟依赖项以确保测试的独立性和重点测试组件的功能。gomock
和 mockgen
是 Go 社区广泛使用的模拟工具,可以自动生成和使用模拟对象。
该项目源于 Google https://github.com/golang/mock,不幸的是,谷歌不再维护这个项目,现在由 Uber 继续维护:https://github.com/uber-go/mock
1. 安装
1 | go install go.uber.org/mock/mockgen@latest |
你可以在项目内使用mockgen
命令,也可以将你的 GOPATH 的 bin 目录添加在 PATH 中,这样就可以全局使用 mockgen
命令。
2. 创建接口
Mockgen 通过接口生成模拟对象。首先定义一个接口,示例如下:
1 | package store |
3. 生成模拟对象
使用 mockgen 生成模拟对象,-destination
代表模拟对象的保存路径,-source
代表模拟对象的来源,会模拟该文件下的所有接口。
1 | 将 $$file 替换成你的文件 |
这将生成一个模拟对象到 /internal/pkg/mock/
目录。
4. 使用模拟对象编写测试
生成模拟对象后,可以在测试中使用它。这里是一个使用模拟 UserRepository 的测试示例:
1 | package store_test |
备注:mockCtrl.Finish()
已经在uber-gomock中被弃用,不需要再手动调用。
5. 理解 EXPECT() 调用
EXPECT()
方法用于设置模拟对象应该如何响应调用。你可以指定方法应当接收的参数和返回的结果。例如:
gomock.Any()
:接受任何值。gomock.Eq(value)
:期待与value
相等的参数。Return(values...)
:指定方法调用后返回的结果。
gomock提供了大量match方法,供我们使用:
All
描述:如果传递给它的所有匹配器都返回真,则返回真。
例子:
1
All(Eq(1), Not(Eq(2))).Matches(1) // 返回 true
Any
描述:返回一个始终匹配的匹配器。
例子:
1
Any().Matches("any value") // 返回 true
Cond
描述:返回一个匹配器,该匹配器根据传递给模拟函数的参数执行给定函数后返回真。
例子:
1
Cond(func(x any) bool { return x.(int) == 1 }).Matches(1) // 返回 true
AnyOf
描述:如果至少一个匹配器返回真,则返回真。
例子:
1
2AnyOf(1, 2, 3).Matches(2) // 返回 true
AnyOf(Nil(), Len(2)).Matches(nil) // 返回 true
Eq
描述:返回一个在等值情况下匹配的匹配器。
例子:
1
Eq(5).Matches(5) // 返回 true
Len
描述:返回一个根据长度匹配的匹配器。如果与不是数组、通道、映射、切片或字符串的类型比较,将返回 false。
例子:
1
Len(3).Matches([]int{1, 2, 3}) // 返回 true
Nil
描述:如果接收到的值是 nil,则匹配。
例子:
1
2var x *bytes.Buffer
Nil().Matches(x) // 返回 true
Not
描述:反转给定子匹配器的结果。
例子:
1
Not(Eq(5)).Matches(4) // 返回 true
Regex
描述:检查参数是否与关联的正则表达式匹配。
例子:
1
Regex("[0-9]{2}:[0-9]{2}").Matches("23:02") // 返回 true
AssignableToTypeOf
描述:如果模拟函数的参数可分配给此函数参数的类型,则匹配。
例子:
1
2var s fmt.Stringer = &bytes.Buffer{}
AssignableToTypeOf(s).Matches(time.Second) // 返回 false
InAnyOrder
描述:对于元素相同但顺序可能不同的集合返回真。
例子:
1
InAnyOrder([]int{1, 2, 3}).Matches([]int{1, 3, 2}) // 返回 true
项目中最佳实践
接下来我将会结合目前云游戏使用的主流框架kratos
进行最佳实践,特别是在使用 assert
库、利用 mock
和 testing
包进行单元测试,以及针对业务逻辑层(biz)和数据访问层(repo)进行有效测试的策略。
注意:该最佳实践代表我在日常使用中采取的我自认为最佳的使用方式,如果你有更好的使用方式,欢迎提出。
1. 结合 assert
使用
使用 assert
库可以让测试断言变得更简单、更直观。assert
提供了一系列丰富的断言方法,可以极大地提高测试的可读性和维护性。
实践示例:
1 | // before |
在这个示例中,assert.Equal
检查 Add
函数的输出是否符合预期。如果不符,测试将输出错误信息并标示失败。
2. biz 层测试
业务逻辑层(Biz layer)是处理复杂业务逻辑的地方。为这一层编写测试非常重要,可以确保业务逻辑的正确性和健壮性。
借助依赖注入(wire),我们可以mock掉repo层进行测试。
a. 使用 Table-Driven Tests 进行结构化测试
Table-Driven Tests(表驱动测试)是一种组织单元测试的方法,通过定义一组测试用例(每个用例是一组输入和预期输出)来简化测试过程。这种方法提高了测试的可维护性和可扩展性。
测试用例结构和执行:
1 | // 定义测试用例结构体 |
b. 初始化 Mock 对象
在单元测试中,初始化 mock 对象是模拟外部依赖和控制测试流程的关键步骤。我们使用 gomock 来创建和配置 mock 对象,以模拟实际的服务和接口。
初始化和配置 Mock 服务:
1 | // 初始化 mock client |
c. 设置 EXPECT 调用和自定义匹配器
设置 EXPECT 调用是定义 mock 对象应如何响应不同输入的过程。我们可以使用 gomock 的 EXPECT() 方法来预设这些响应,以及使用自定义匹配器来处理复杂的验证逻辑。
配置 EXPECT 调用和使用匹配器:
1 | func TestGameFSM(tIn *testing.T) { |
1 | // 自定义gomock匹配器 gomock.Cond() |
d. 管理 FSM 事件和状态变化
在单元测试中,管理有限状态机(FSM)的事件和状态转换是验证业务逻辑正确性的重要部分。我们需要监控 FSM 的状态变化,并确保它符合预期。
处理 FSM 事件和验证状态:
1 | fsm := tt.args(t) |
完整示例
1 | package biz |
- 特别注意:
t *testing.T
需要在子测试逐层传入,不可夸层引用外部的 t - 例子中的最外层控制器:tExt,for循环中的控制器:t,子测试中的控制器:tOut
最终效果:
3. repo 层测试
在使用 Kratos 框架的项目中,测试数据访问层(repo layer)是确保数据操作准确性和稳定性的关键步骤。这部分教程专注于如何设置和执行 repo 层的单元测试,确保与数据库的交互符合预期的业务逻辑。
a. 设置环境和初始化数据库连接
首先,需要确保测试环境能够正确连接到数据库。我们使用 sync.Once
确保数据库连接的初始化代码只执行一次,避免在多个测试中重复初始化。示例中的 getRepo
函数负责创建数据库连接,并初始化 repo 对象。
1 | func getRepo() error { |
b. 整体测试执行
使用 TestMain
函数来管理测试的初始化和结束工作。如果数据库连接失败,测试将不会执行。通过调用 os.Exit
与返回的状态码,可以控制测试失败时的行为。
1 | func TestMain(m *testing.M) { |
完整示例
1 | package repo |
参考文章
- Go Test 单元测试简明教程
- Use Fatalf instead of Errorf in assert
- Controller needs to be initialized inside subtests
- Benchmarking in Golang: Improving function performance
- Deprecate the function ctrl.Finish()
- uber-go
特别感谢:ChatGPT 4