Go语言单元测试

单元测试是保证项目质量、简化debug和验证代码功能的重要手段。在Go语言中,利用testing包可以方便地编写和执行单元测试,利用mockgen包可以模拟待测试函数的依赖对象。本教程将介绍如何在Go语言中写好单元测试,涵盖从基本测试到复杂场景的应用,并结合目前云游戏使用的主流框架kratos进行最佳实践。

testing 基础教程

1. 编写基本的单元测试

在Go语言中,每个测试文件以 _test.go 结尾,放置于与源代码相同的目录下。例如,有文件 calc.go,对应的测试文件应该命名为 calc_test.go

  • 测试用例名称一般命名为 Test 加上待测试的方法名
  • 测试用的参数有且只有一个,在这里是 t *testing.T

示例:测试 AddMul 函数

假设 calc.go 的内容如下:

1
2
3
4
5
6
7
8
9
package main

func Add(a int, b int) int {
return a + b
}

func Mul(a int, b int) int {
return a * b
}

对应的 calc_test.go 可以写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import "testing"

func TestAdd(t *testing.T) {
if ans := Add(1, 2); ans != 3 {
t.Errorf("1 + 2 expected to be 3, but got %d", ans)
}

if ans := Add(-10, -20); ans != -30 {
t.Errorf("-10 + -20 expected to be -30, but got %d", ans)
}
}

运行测试命令:

1
go test -v

2. 使用子测试 (Subtests)

子测试允许在一个测试函数中定义多个小测试,这是通过 t.Run 实现的。这种方式使得测试结果更加模块化。

示例:使用子测试对 Mul 函数进行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func TestMul(t *testing.T) {
t.Run("positive", func(t *testing.T) {
if Mul(2, 3) != 6 {
t.Fatal("2 * 3 expected to be 6")
}
})

t.Run("negative", func(t *testing.T) {
if Mul(2, -3) != -6 {
t.Fatal("2 * -3 expected to be -6")
}
})

t.Run("zero", func(t *testing.T) {
if Mul(2, 0) != 0 {
t.Fatal("2 * 0 expected to be 0")
}
})
}

对于多个子测试的场景,更推荐如下的写法(table-driven tests):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//  calc_test.go
func TestMul(t *testing.T) {
cases := []struct {
Name string
A, B, Expected int
}{
{"pos", 2, 3, 6},
{"neg", 2, -3, -6},
{"zero", 2, 0, 0},
}

for _, c := range cases {
t.Run(c.Name, func(t *testing.T) {
if ans := Mul(c.A, c.B); ans != c.Expected {
t.Fatalf("%d * %d expected %d, but %d got",
c.A, c.B, c.Expected, ans)
}
})
}
}

所有用例的数据组织在切片 cases 中,看起来就像一张表,借助循环创建子测试。这样写的好处有:

  • 新增用例非常简单,只需给 cases 新增一条测试数据即可。
  • 测试代码可读性好,直观地能够看到每个子测试的参数和期待的返回值。
  • 用例失败时,报错信息的格式比较统一,测试报告易于阅读。

3. t.Error 还是 t.Fatal

t.Error/t.Errorft.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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func setup() error {
fmt.Println("Before all tests")
// Do what you when to do...
return nil
}

func teardown() error {
fmt.Println("After all tests")
// Clean the floor.
return nil
}

func Test1(t *testing.T) {
fmt.Println("I'm test1")
}

func Test2(t *testing.T) {
fmt.Println("I'm test2")
}

func TestMain(m *testing.M) {
if err := setup();err != nil {
fmt.Println("setup error! stop tests!")
return
}
code := m.Run()
teardown()
os.Exit(code)
}

5. 网络测试

测试涉及到网络操作时,可以使用真实的TCP/HTTP连接,推荐使用 net/http/httptest 进行更加控制的测试。

示例:使用 httptest 测试HTTP处理函数

1
2
3
4
5
6
7
8
9
10
11
12
func TestHelloHandler(t *testing.T) {
req := httptest.NewRequest("GET", "http://example.com/hello", nil)
resp := httptest.NewRecorder()
helloHandler(resp, req)

got := resp.Body.String()
want := "hello world"

if got != want {
t.Errorf("got %q, want %q", got, want)
}
}

6. 基准测试 (Benchmark Tests)

基准测试用于测量和评估代码段的性能。通过比较不同实现的性能指标,你可以优化你的代码。

  • 在Go语言中,基准测试的函数名必须以 Benchmark 开头,并接受一个类型为 *testing.B 的参数。

示例:基准测试 Add 函数

1
2
3
4
5
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(1, 2)
}
}

执行基准测试,使用 -bench 参数,-benchmem表示显示内存分配信息:

1
2
3
4
$ go test -benchmem -bench=.
...
BenchmarkAdd-16 15991854 71.6 ns/op 5 B/op 1 allocs/op
...

基准测试报告每一列值对应的含义如下:

1
2
3
4
5
6
7
8
type BenchmarkResult struct {
N int // 总迭代次数
T time.Duration // 每次基准测试花费的时间
Bytes int64 // 平均每次迭代处理的字节数 (-benchmem)
MemAllocs uint64 // 平均每次分配内存的次数 (-benchmem)
// 这向我们表明,在所有测试中,以 1 次分配/操作的平均速率分配 5 字节内存。
...
}

如果在运行前基准测试需要一些耗时的配置,则可以使用 b.ResetTimer() 先重置定时器,例如:

1
2
3
4
5
6
7
func BenchmarkHello(b *testing.B) {
... // 耗时操作
b.ResetTimer()
for i := 0; i < b.N; i++ {
fmt.Sprintf("hello")
}
}

7. 使用并发性能测试 (Parallel Tests)

基准测试提供并发性能测试,并发性能测试有助于理解代码在高并发条件下的表现。

示例:并发HTTP请求处理性能测试

假设我们有一个处理HTTP GET请求的函数,该函数返回一个简单的欢迎消息。我们将使用Go的net/http/httptest库来模拟HTTP请求和响应。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package main

import (
"net/http"
"net/http/httptest"
"testing"
)

// helloWorldHandler 返回一个简单的欢迎消息
func helloWorldHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, World!"))
}

func BenchmarkHelloWorldHandler(b *testing.B) {
request := httptest.NewRequest("GET", "http://example.com/hello", nil)
responseRecorder := httptest.NewRecorder()
handler := http.HandlerFunc(helloWorldHandler)

b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
// 每次迭代都复用相同的请求和响应记录器
handler.ServeHTTP(responseRecorder, request)
// 重置响应记录器以便下一个迭代
responseRecorder.Result()
responseRecorder.Body.Reset()
}
})
}

运行基准测试

执行上述基准测试时,使用以下命令行命令:

1
2
3
4
go test -benchmem -bench=.
...
BenchmarkHelloWorldHandler-16 5000000 240 ns/op 32 B/op 1 allocs/op
...

输出解释

  • 5000000 表示测试在配置的时间内执行了约 5000000 次迭代。
  • 240 ns/op 表示每次操作平均耗时 240 纳秒。
  • 32 B/op 表示每次操作平均分配了 32 字节的内存。
  • 1 allocs/op 表示每次操作平均进行了 1 次内存分配。

mockgen 基础教程

在 Go 中编写单元测试时,经常需要模拟依赖项以确保测试的独立性和重点测试组件的功能。gomockmockgen 是 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
2
3
4
5
6
7
package store

// UserRepository 是我们将要模拟的接口
type UserRepository interface {
FindUser(id int) (*User, error)
CreateUser(user *User) error
}

3. 生成模拟对象

使用 mockgen 生成模拟对象,-destination代表模拟对象的保存路径,-source代表模拟对象的来源,会模拟该文件下的所有接口。

1
2
# 将 $$file 替换成你的文件
mockgen -destination="./internal/pkg/mock/$$file" -source="./internal/$$file"

这将生成一个模拟对象到 /internal/pkg/mock/ 目录。

4. 使用模拟对象编写测试

生成模拟对象后,可以在测试中使用它。这里是一个使用模拟 UserRepository 的测试示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package store_test

import (
"testing"
"go.uber.org/mock/gomock"
"github.com/yourusername/yourproject/store"
"github.com/yourusername/yourproject/store/mocks"
)

func TestCreateUser(t *testing.T) {
mockCtrl := gomock.NewController(t)

mockUserRepo := mocks.NewMockUserRepository(mockCtrl)
mockUserRepo.EXPECT().CreateUser(gomock.Any()).Return(nil)

userService := store.UserService{Repository: mockUserRepo}
err := userService.CreateUser(&store.User{ID: 1, Name: "John Doe"})
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
}

备注:mockCtrl.Finish()已经在uber-gomock中被弃用,不需要再手动调用。

5. 理解 EXPECT() 调用

EXPECT() 方法用于设置模拟对象应该如何响应调用。你可以指定方法应当接收的参数和返回的结果。例如:

  • gomock.Any():接受任何值。
  • gomock.Eq(value):期待与 value 相等的参数。
  • Return(values...):指定方法调用后返回的结果。

gomock提供了大量match方法,供我们使用:

  1. All

    • 描述:如果传递给它的所有匹配器都返回真,则返回真。

    • 例子:

      1
      All(Eq(1), Not(Eq(2))).Matches(1) // 返回 true
  2. Any

    • 描述:返回一个始终匹配的匹配器。

    • 例子:

      1
      Any().Matches("any value") // 返回 true
  3. Cond

    • 描述:返回一个匹配器,该匹配器根据传递给模拟函数的参数执行给定函数后返回真。

    • 例子:

      1
      Cond(func(x any) bool { return x.(int) == 1 }).Matches(1) // 返回 true
  4. AnyOf

    • 描述:如果至少一个匹配器返回真,则返回真。

    • 例子:

      1
      2
      AnyOf(1, 2, 3).Matches(2) // 返回 true
      AnyOf(Nil(), Len(2)).Matches(nil) // 返回 true
  5. Eq

    • 描述:返回一个在等值情况下匹配的匹配器。

    • 例子:

      1
      Eq(5).Matches(5) // 返回 true
  6. Len

    • 描述:返回一个根据长度匹配的匹配器。如果与不是数组、通道、映射、切片或字符串的类型比较,将返回 false。

    • 例子:

      1
      Len(3).Matches([]int{1, 2, 3}) // 返回 true
  7. Nil

    • 描述:如果接收到的值是 nil,则匹配。

    • 例子:

      1
      2
      var x *bytes.Buffer
      Nil().Matches(x) // 返回 true
  8. Not

    • 描述:反转给定子匹配器的结果。

    • 例子:

      1
      Not(Eq(5)).Matches(4) // 返回 true
  9. Regex

    • 描述:检查参数是否与关联的正则表达式匹配。

    • 例子:

      1
      Regex("[0-9]{2}:[0-9]{2}").Matches("23:02") // 返回 true
  10. AssignableToTypeOf

    • 描述:如果模拟函数的参数可分配给此函数参数的类型,则匹配。

    • 例子:

      1
      2
      var s fmt.Stringer = &bytes.Buffer{}
      AssignableToTypeOf(s).Matches(time.Second) // 返回 false
  11. InAnyOrder

    • 描述:对于元素相同但顺序可能不同的集合返回真。

    • 例子:

      1
      InAnyOrder([]int{1, 2, 3}).Matches([]int{1, 3, 2}) // 返回 true

项目中最佳实践

接下来我将会结合目前云游戏使用的主流框架kratos进行最佳实践,特别是在使用 assert 库、利用 mocktesting 包进行单元测试,以及针对业务逻辑层(biz)和数据访问层(repo)进行有效测试的策略。

注意:该最佳实践代表我在日常使用中采取的我自认为最佳的使用方式,如果你有更好的使用方式,欢迎提出。

1. 结合 assert 使用

使用 assert 库可以让测试断言变得更简单、更直观。assert 提供了一系列丰富的断言方法,可以极大地提高测试的可读性和维护性。

实践示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// before
func TestFunc(t *testing.T) {
result, err := Add(1, 2)
if err != nil {
t.Error("err got unexpect error = ", err)
}
if result != 3 {
t.Error("result got unexpect num = ", result)
}
}

// after
func TestAdd(t *testing.T) {
result, err := Add(1, 2)
assert.NoError(err)
assert.Equal(t, 3, result, "Add(1, 2) should be equal to 3")
}

在这个示例中,assert.Equal 检查 Add 函数的输出是否符合预期。如果不符,测试将输出错误信息并标示失败。

2. biz 层测试

业务逻辑层(Biz layer)是处理复杂业务逻辑的地方。为这一层编写测试非常重要,可以确保业务逻辑的正确性和健壮性。

借助依赖注入(wire),我们可以mock掉repo层进行测试。

a. 使用 Table-Driven Tests 进行结构化测试

Table-Driven Tests(表驱动测试)是一种组织单元测试的方法,通过定义一组测试用例(每个用例是一组输入和预期输出)来简化测试过程。这种方法提高了测试的可维护性和可扩展性。

测试用例结构和执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 定义测试用例结构体
tests := []struct {
name string // 测试用例名称
args func(t *testing.T) *_fsm.FSM // 测试用例输入:FSM
event string // 测试用例输入:事件
wantState string // 预期输出:最终状态
wantErr bool // 预期输出:是否有错误
}{
// 测试用例定义,每个用例是一个安装、更新或部署场景
}

// 运行测试用例
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 这里进行具体的测试逻辑
})
}

b. 初始化 Mock 对象

在单元测试中,初始化 mock 对象是模拟外部依赖和控制测试流程的关键步骤。我们使用 gomock 来创建和配置 mock 对象,以模拟实际的服务和接口。

初始化和配置 Mock 服务

1
2
3
4
5
6
7
8
9
10
11
12
// 初始化 mock client
func initGameFsmMock(t *testing.T) *testGameFSM {
ctrl := gomock.NewController(t)
mobileClient := mock_wire.NewMockPhoneClient(ctrl)
autoClient := mock_wire.NewMockGameAutoTaskClient(ctrl)
// 更多 mock service 初始化...
return &testGameFSM{
mobileClient: mobileClient,
autoClient: autoClient,
// 其他成员初始化...
}
}

c. 设置 EXPECT 调用和自定义匹配器

设置 EXPECT 调用是定义 mock 对象应如何响应不同输入的过程。我们可以使用 gomock 的 EXPECT() 方法来预设这些响应,以及使用自定义匹配器来处理复杂的验证逻辑。

配置 EXPECT 调用和使用匹配器

1
2
3
4
5
6
7
8
9
func TestGameFSM(tIn *testing.T) {
mockClient := initGameFsmMock(tIn)
// 设置 mock 期望
mockClient.mobileClient.EXPECT().InstallApk(gomock.Any(), gomock.Any()).Return(...)

// 自定义匹配器示例
mockClient.taskRepo.EXPECT().UpdateSItem(gomock.Any(), gomock.Cond(statusAssert(tIn, v1.CommonStatus_GAME_INSTALLING, ""))).Return(nil)
// 继续配置其他 EXPECT 调用...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 自定义gomock匹配器 gomock.Cond()
func statusAssert(t *testing.T, status v1.CommonStatus, remark string) func(x any) bool {
return func(x any) bool {
item, ok := x.(*taskDos.STaskItemDo)
if !ok {
return false
}
if dos.IsEndStatus(item.Status.String()) || item.Status == v1.CommonStatus_TASK_EXECUTION_SUCCESS {
compareTime := time.Date(2024, time.April, 10, 12, 0, 0, 0, time.UTC)
if item.EndedAt.Before(compareTime) {
fmt.Printf("item.EndTime: %v, compareTime: %v\n", item.EndedAt, compareTime)
return false
}
}
return item.Status == status && item.Remark == remark
}
}

d. 管理 FSM 事件和状态变化

在单元测试中,管理有限状态机(FSM)的事件和状态转换是验证业务逻辑正确性的重要部分。我们需要监控 FSM 的状态变化,并确保它符合预期。

处理 FSM 事件和验证状态

1
2
3
4
5
6
7
8
fsm := tt.args(t)
err := fsm.Event(ctx, tt.event)
if fsm.Current() == tt.wantState {
// 状态正确
}
if err != nil {
// 处理错误
}

完整示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
package biz

import (
"context"
"fmt"
_fsm "github.com/looplab/fsm"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
v1 "rock-stack/api/cgboss/v1"
"rock-stack/apps/cgboss/internal/biz/game"
"rock-stack/apps/cgboss/internal/biz/game/dos"
taskDos "rock-stack/apps/cgboss/internal/biz/task/dos"
vm "rock-stack/apps/cgboss/internal/biz/vm/dos"
"rock-stack/apps/cgboss/internal/biz/wire"
mock_wire "rock-stack/apps/cgboss/internal/pkg/mock/biz/wire"
"sort"
"testing"
"time"
)

type testGameFSM struct {
mobileClient *mock_wire.MockPhoneClient
autoClient *mock_wire.MockGameAutoTaskClient
luci *mock_wire.MockLuciClient
taskRepo *mock_wire.MockTaskRepo
iaasFactory wire.IaasFactory
iaasAli *mock_wire.MockIaasClient
}

// 初始化mock client
func initGameFsmMock(t *testing.T) *testGameFSM {
ctrl := gomock.NewController(t)
mobileClient := mock_wire.NewMockPhoneClient(ctrl)
autoClient := mock_wire.NewMockGameAutoTaskClient(ctrl)
luci := mock_wire.NewMockLuciClient(ctrl)
taskRepo := mock_wire.NewMockTaskRepo(ctrl)
iaasAli := mock_wire.NewMockIaasClient(ctrl)
iaasFactory := func(cloudVendor vm.CloudVendorType) (wire.IaasClient, error) {
switch cloudVendor {
case vm.CloudVendorAli:
return iaasAli, nil
default:
return nil, errors.New("IaasClient not found")
}
}
return &testGameFSM{
mobileClient: mobileClient,
autoClient: autoClient,
luci: luci,
iaasFactory: iaasFactory,
taskRepo: taskRepo,
iaasAli: iaasAli,
}
}

// 自定义gomock匹配器 gomock.Cond()
func statusAssert(t *testing.T, status v1.CommonStatus, remark string) func(x any) bool {
return func(x any) bool {
item, ok := x.(*taskDos.STaskItemDo)
if !ok {
return false
}
if dos.IsEndStatus(item.Status.String()) || item.Status == v1.CommonStatus_TASK_EXECUTION_SUCCESS {
compareTime := time.Date(2024, time.April, 10, 12, 0, 0, 0, time.UTC)
if item.EndedAt.Before(compareTime) {
fmt.Printf("item.EndTime: %v, compareTime: %v\n", item.EndedAt, compareTime)
return false
}
}
return item.Status == status && item.Remark == remark
}
}

// 自定义gomock匹配器 gomock.Cond()
func statusBatchAssert(t *testing.T, tasks []*taskDos.STaskItemDo) func(x any) bool {
return func(x any) bool {
items, ok := x.([]*taskDos.STaskItemDo)
if !ok {
return false
}
sort.Slice(items, func(i, j int) bool {
return items[i].SItemID < items[j].SItemID
})
for i := range items {
if dos.IsEndStatus(items[i].Status.String()) || items[i].Status == v1.CommonStatus_TASK_EXECUTION_SUCCESS {
compareTime := time.Date(2024, time.April, 10, 12, 0, 0, 0, time.UTC)
if items[i].EndedAt.Before(compareTime) {
fmt.Printf("item.EndTime: %v, compareTime: %v\n", items[i].EndedAt, compareTime)
return false
}
}
if items[i].Status != tasks[i].Status || items[i].Remark != tasks[i].Remark {
return false
}
}
return true
}
}

func TestGameFSM(tExt *testing.T) {
// 使用参数子测试
tests := []struct {
name string // 测试用例名称
args func(t *testing.T) *_fsm.FSM // 测试用例输入:FSM
event string // 测试用例输入:event
wantState string // 测试用例预期输出:最终状态
wantErr bool // 测试用例预期输出:是否有Error
}{
{
name: "install success", // 安装成功的测试用例
args: func(tIn *testing.T) *_fsm.FSM {
mockClient := initGameFsmMock(tIn)
// 使用 gomock 控制依赖的预期输入输出
mockClient.mobileClient.EXPECT().InstallApk(gomock.Any(), gomock.Any()).Return(&dos.InstallApkRsp{
TaskId: "test_task_id",
}, nil)
mockClient.mobileClient.EXPECT().GetTaskStatus(gomock.Any(), &dos.GetTaskStatusReq{TaskId: "test_task_id"}).
Return(&dos.GetTaskStatusRsp{
TaskStatus: "success",
}, nil)
mockClient.luci.EXPECT().GetGameInfo(gomock.Any(), gomock.Any()).Return(&dos.GameInfoContent{
Portrait: 0,
}, nil)
mockClient.autoClient.EXPECT().CreateAutoTask(gomock.Any(), gomock.Any()).Return(&dos.CreateAutoTaskResponse{
TaskID: 123,
}, nil)
mockClient.autoClient.EXPECT().GetAutoTask(gomock.Any(), gomock.Any()).Return(&dos.GetAutoTaskResponse{
TaskStatus: 2,
GameStatus: 2,
ErrDesc: "haha",
}, nil)
mockClient.taskRepo.EXPECT().UpdateSItem(gomock.Any(), gomock.Cond(statusAssert(tIn, v1.CommonStatus_GAME_INSTALLING, ""))).Return(nil)
mockClient.taskRepo.EXPECT().UpdateSItem(gomock.Any(), gomock.Cond(statusAssert(tIn, v1.CommonStatus_GAME_INSTALLATION_SUCCESS, ""))).Return(nil)
mockClient.taskRepo.EXPECT().UpdateSItem(gomock.Any(), gomock.Cond(statusAssert(tIn, v1.CommonStatus_GAME_UPDATING, ""))).Return(nil)
mockClient.taskRepo.EXPECT().UpdateSItem(gomock.Any(), gomock.Cond(statusAssert(tIn, v1.CommonStatus_GAME_UPDATE_SUCCESS, ""))).Return(nil)

// 构造FSM
b := game.NewGameFSM(mockClient.mobileClient, mockClient.autoClient, mockClient.luci, mockClient.iaasFactory, mockClient.taskRepo)
fsm, _ := b.GetFsm(dos.InstallQueued, b.ParamInstall(&dos.InstallGameDo{
InstanceId: "test_instance_id",
FileUrl: "test_file_url",
}, &dos.UpdateGameDo{
BizType: 1,
Username: "test_username",
Gid: 1,
EdgeId: 1,
Vmid: 1,
}))
fsm.SetMetadata(dos.MetaKeyTaskInfo, &taskDos.STaskItemDo{
SItemID: 1,
})
return fsm
},
event: dos.InstallGame,
wantState: v1.CommonStatus_GAME_UPDATE_SUCCESS.String(),
wantErr: false,
},
{
name: "install failed: mobileClient InstallApk failed", // 安装失败的测试用例: 原因
args: func(tIn *testing.T) *_fsm.FSM {
mockClient := initGameFsmMock(tIn)
mockClient.mobileClient.EXPECT().InstallApk(gomock.Any(), gomock.Any()).Return(&dos.InstallApkRsp{
TaskId: "test_task_id",
}, errors.New("mock mobileClient InstallApk failed"))
mockClient.taskRepo.EXPECT().UpdateSItem(gomock.Any(), gomock.Cond(statusAssert(tIn, v1.CommonStatus_GAME_INSTALLATION_FAILED, "mock mobileClient InstallApk failed: mobileClient FetchInstall failed"))).Return(nil)

b := game.NewGameFSM(mockClient.mobileClient, mockClient.autoClient, mockClient.luci, mockClient.iaasFactory, mockClient.taskRepo)
fsm, _ := b.GetFsm(dos.InstallQueued, b.ParamInstall(&dos.InstallGameDo{
InstanceId: "test_instance_id",
FileUrl: "test_file_url",
}, &dos.UpdateGameDo{
BizType: 1,
Username: "test_username",
Gid: 1,
EdgeId: 1,
Vmid: 1,
}))
fsm.SetMetadata(dos.MetaKeyTaskInfo, &taskDos.STaskItemDo{
SItemID: 1,
})
return fsm
},
event: dos.InstallGame,
wantState: v1.CommonStatus_GAME_INSTALLATION_FAILED.String(),
wantErr: true,
}{
name: "deploy: DeployImage Error", // 部署失败的测试用例: 原因
args: func(tIn *testing.T) *_fsm.FSM {
mockClient := initGameFsmMock(tIn)
mockClient.iaasAli.EXPECT().DeployImage(gomock.Any(), &vm.DeployImageRequest{
ImageId: "test_image_id",
InstanceIds: []string{"test_instance_id_1", "test_instance_id_2"},
}).Return(nil, errors.New("mock DeployImage Error"))
mockClient.taskRepo.EXPECT().UpdateSItems(gomock.Any(), gomock.Cond(statusBatchAssert(tIn, []*taskDos.STaskItemDo{
{
PItemID: 1,
SItemID: 1,
Status: v1.CommonStatus_INSTANCE_IMAGE_DEPLOY_FAILED,
Remark: "mock DeployImage Error",
},
{
PItemID: 1,
SItemID: 2,
Status: v1.CommonStatus_INSTANCE_IMAGE_DEPLOY_FAILED,
Remark: "mock DeployImage Error",
},
}))).Return(nil)

b := game.NewGameFSM(mockClient.mobileClient, mockClient.autoClient, mockClient.luci, mockClient.iaasFactory, mockClient.taskRepo)
fsm, _ := b.GetFsm(dos.ImageDistributedSuccess, b.ParamDeployImage("ens", &dos.DeployImageDo{
ImageID: "test_image_id",
InstanceIDs: []string{"test_instance_id_1", "test_instance_id_2"},
}))
fsm.SetMetadata(dos.MetaKeyTaskInfo, map[string]*taskDos.STaskItemDo{
"test_instance_id_1": {
PItemID: 1,
SItemID: 1,
},
"test_instance_id_2": {
PItemID: 1,
SItemID: 2,
},
})
return fsm
},
event: dos.DeployImage,
wantState: dos.ImageDeployedFailed,
wantErr: true,
}
}

// 挨个运行子测试函数
for _, tt := range tests {
tExt.Run(tt.name, func(t *testing.T) {
r := assert.New(t)
fsm := tt.args(t)
var gotErr error
ctx, cancel := context.WithCancel(context.Background())
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
go func() {
<-time.After(3000 * time.Second)
cancel()
}()
loop:
for {
select {
case <-ticker.C:
err := fsm.Event(ctx, tt.event)
if err != nil {
var canceledErr _fsm.CanceledError
if errors.As(err, &canceledErr) {
if errors.Is(canceledErr.Err, dos.ErrTaskAsync) {
continue
}
gotErr = canceledErr.Err
} else {
gotErr = err
}
_ = fsm.Event(ctx, dos.Failure)
break loop
}
if fsm.Current() == tt.wantState {
break loop
}
case <-ctx.Done():
// 外部 context 被取消,停止 ticker 并退出循环
fmt.Println("context canceled")
break loop
}
}

if tt.wantErr {
r.Error(gotErr)
t.Log(gotErr.Error())
} else {
r.NoError(gotErr)
}
r.Equal(tt.wantState, fsm.Current())
})
}
}
  • 特别注意:t *testing.T 需要在子测试逐层传入,不可夸层引用外部的 t
  • 例子中的最外层控制器:tExt,for循环中的控制器:t,子测试中的控制器:tOut

最终效果:

image-20240419132959457

3. repo 层测试

在使用 Kratos 框架的项目中,测试数据访问层(repo layer)是确保数据操作准确性和稳定性的关键步骤。这部分教程专注于如何设置和执行 repo 层的单元测试,确保与数据库的交互符合预期的业务逻辑。

a. 设置环境和初始化数据库连接

首先,需要确保测试环境能够正确连接到数据库。我们使用 sync.Once 确保数据库连接的初始化代码只执行一次,避免在多个测试中重复初始化。示例中的 getRepo 函数负责创建数据库连接,并初始化 repo 对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func getRepo() error {
once.Do(func() {
gdbOnce, err := db.NewMysqlClient(
db.WithSource("root:123456@tcp(127.0.0.1:3308)/d_cgboss_info?charset=utf8mb4&parseTime=true&loc=Local&timeout=5s"),
)
if err != nil {
return
}
gdb = gdbOnce
gameRepo = repo.NewGameRepo(&data.Data{})
})
if gdb == nil {
return errors.New("测试数据库连接失败,跳过 repo 测试(请忽略上面的error)")
}
ctx = context.WithValue(context.Background(), data.ContextTxKey{}, gdb.Debug())
return nil
}

b. 整体测试执行

使用 TestMain 函数来管理测试的初始化和结束工作。如果数据库连接失败,测试将不会执行。通过调用 os.Exit 与返回的状态码,可以控制测试失败时的行为。

1
2
3
4
5
6
7
8
func TestMain(m *testing.M) {
err := getRepo()
if err != nil {
os.Exit(0)
}
code := m.Run()
os.Exit(code)
}

完整示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
package repo

import (
"context"
"encoding/json"
"errors"
"os"
v1 "rock-stack/api/cgboss/v1"
"rock-stack/apps/cgboss/internal/biz/wire"
"rock-stack/apps/cgboss/internal/data"
"rock-stack/apps/cgboss/internal/data/repo"
"rock-stack/pkg/db"
"sync"
"testing"

"gorm.io/gorm"
)

var (
gdb *gorm.DB
ctx context.Context
once sync.Once
gameRepo wire.GameRepo
)

func getRepo() error {
once.Do(func() {
gdbOnce, err := db.NewMysqlClient(
db.WithSource("root:123456@tcp(127.0.0.1:3308)/d_cgboss_info?charset=utf8mb4&parseTime=true&loc=Local&timeout=5s"),
)
if err != nil {
return
}
gdb = gdbOnce
gameRepo = repo.NewGameRepo(&data.Data{})
})
if gdb == nil {
return errors.New("测试数据库连接失败,跳过 repo 测试(请忽略上面的error)")
}
ctx = context.WithValue(context.Background(), data.ContextTxKey{}, gdb.Debug())
return nil
}

func Prettify(i interface{}) string {
resp, _ := json.MarshalIndent(i, "", " ")
return string(resp)
}

func TestListGameVersionAndSetVersion(t *testing.T) {
res, _, err := gameRepo.ListGame(ctx, &v1.PageOptions{Size: 10, No: 1}, gameRepo.WithSetVersionId(1))
if err != nil {
t.Error(err)
return
}
t.Log(Prettify(res))
}

func TestListGameWithoutVersion(t *testing.T) {
res, _, err := gameRepo.ListGameWithoutVersion(ctx, &v1.PageOptions{Size: 2, No: 1}, gameRepo.WithGameSetId(1), gameRepo.WithGameStatus(188))
if err != nil {
t.Error(err)
return
}
t.Log(Prettify(res))
}

func TestMain(m *testing.M) {
err := getRepo()
if err != nil {
os.Exit(0)
}
code := m.Run()
os.Exit(code)
}

参考文章

特别感谢:ChatGPT 4