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 的内容如下:
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 可以写成:
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)
}
}
运行测试命令:
go test -v
2. 使用子测试 (Subtests)
子测试允许在一个测试函数中定义多个小测试,这是通过 t.Run 实现的。这种方式使得测试结果更加模块化。
示例:使用子测试对 Mul 函数进行测试
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):
// 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.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 测试需要前置操作的函数
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处理函数
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 函数
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(1, 2)
}
}
执行基准测试,使用 -bench 参数,-benchmem表示显示内存分配信息:
$ go test -benchmem -bench=.
...
BenchmarkAdd-16 15991854 71.6 ns/op 5 B/op 1 allocs/op
...
基准测试报告每一列值对应的含义如下:
type BenchmarkResult struct {
N int // 总迭代次数
T time.Duration // 每次基准测试花费的时间
Bytes int64 // 平均每次迭代处理的字节数 (-benchmem)
MemAllocs uint64 // 平均每次分配内存的次数 (-benchmem)
// 这向我们表明,在所有测试中,以 1 次分配/操作的平均速率分配 5 字节内存。
...
}
如果在运行前基准测试需要一些耗时的配置,则可以使用 b.ResetTimer() 先重置定时器,例如:
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请求和响应。
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()
}
})
}
运行基准测试
执行上述基准测试时,使用以下命令行命令:
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 中编写单元测试时,经常需要模拟依赖项以确保测试的独立性和重点测试组件的功能。gomock 和 mockgen 是 Go 社区广泛使用的模拟工具,可以自动生成和使用模拟对象。
该项目源于 Google https://github.com/golang/mock,不幸的是,谷歌不再维护这个项目,现在由 Uber 继续维护:https://github.com/uber-go/mock
1. 安装
go install go.uber.org/mock/mockgen@latest
你可以在项目内使用mockgen命令,也可以将你的 GOPATH 的 bin 目录添加在 PATH 中,这样就可以全局使用 mockgen 命令。
2. 创建接口
Mockgen 通过接口生成模拟对象。首先定义一个接口,示例如下:
package store
// UserRepository 是我们将要模拟的接口
type UserRepository interface {
FindUser(id int) (*User, error)
CreateUser(user *User) error
}
3. 生成模拟对象
使用 mockgen 生成模拟对象,-destination代表模拟对象的保存路径,-source代表模拟对象的来源,会模拟该文件下的所有接口。
# 将 $$file 替换成你的文件
mockgen -destination="./internal/pkg/mock/$$file" -source="./internal/$$file"
这将生成一个模拟对象到 /internal/pkg/mock/ 目录。
4. 使用模拟对象编写测试
生成模拟对象后,可以在测试中使用它。这里是一个使用模拟 UserRepository 的测试示例:
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方法,供我们使用:
-
All
-
描述:如果传递给它的所有匹配器都返回真,则返回真。
-
例子:
All(Eq(1), Not(Eq(2))).Matches(1) // 返回 true
-
-
Any
-
描述:返回一个始终匹配的匹配器。
-
例子:
Any().Matches("any value") // 返回 true
-
-
Cond
-
描述:返回一个匹配器,该匹配器根据传递给模拟函数的参数执行给定函数后返回真。
-
例子:
Cond(func(x any) bool { return x.(int) == 1 }).Matches(1) // 返回 true
-
-
AnyOf
-
描述:如果至少一个匹配器返回真,则返回真。
-
例子:
AnyOf(1, 2, 3).Matches(2) // 返回 true AnyOf(Nil(), Len(2)).Matches(nil) // 返回 true
-
-
Eq
-
描述:返回一个在等值情况下匹配的匹配器。
-
例子:
Eq(5).Matches(5) // 返回 true
-
-
Len
-
描述:返回一个根据长度匹配的匹配器。如果与不是数组、通道、映射、切片或字符串的类型比较,将返回 false。
-
例子:
Len(3).Matches([]int{1, 2, 3}) // 返回 true
-
-
Nil
-
描述:如果接收到的值是 nil,则匹配。
-
例子:
var x *bytes.Buffer Nil().Matches(x) // 返回 true
-
-
Not
-
描述:反转给定子匹配器的结果。
-
例子:
Not(Eq(5)).Matches(4) // 返回 true
-
-
Regex
-
描述:检查参数是否与关联的正则表达式匹配。
-
例子:
Regex("[0-9]{2}:[0-9]{2}").Matches("23:02") // 返回 true
-
-
AssignableToTypeOf
-
描述:如果模拟函数的参数可分配给此函数参数的类型,则匹配。
-
例子:
var s fmt.Stringer = &bytes.Buffer{} AssignableToTypeOf(s).Matches(time.Second) // 返回 false
-
-
InAnyOrder
-
描述:对于元素相同但顺序可能不同的集合返回真。
-
例子:
InAnyOrder([]int{1, 2, 3}).Matches([]int{1, 3, 2}) // 返回 true
-
项目中最佳实践
接下来我将会结合目前云游戏使用的主流框架kratos进行最佳实践,特别是在使用 assert 库、利用 mock 和 testing 包进行单元测试,以及针对业务逻辑层(biz)和数据访问层(repo)进行有效测试的策略。
注意:该最佳实践代表我在日常使用中采取的我自认为最佳的使用方式,如果你有更好的使用方式,欢迎提出。
1. 结合 assert 使用
使用 assert 库可以让测试断言变得更简单、更直观。assert 提供了一系列丰富的断言方法,可以极大地提高测试的可读性和维护性。
实践示例:
// 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(表驱动测试)是一种组织单元测试的方法,通过定义一组测试用例(每个用例是一组输入和预期输出)来简化测试过程。这种方法提高了测试的可维护性和可扩展性。
测试用例结构和执行:
// 定义测试用例结构体
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 服务:
// 初始化 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 调用和使用匹配器:
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 调用...
}
// 自定义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 事件和验证状态:
fsm := tt.args(t)
err := fsm.Event(ctx, tt.event)
if fsm.Current() == tt.wantState {
// 状态正确
}
if err != nil {
// 处理错误
}
完整示例
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
最终效果:

3. repo 层测试
在使用 Kratos 框架的项目中,测试数据访问层(repo layer)是确保数据操作准确性和稳定性的关键步骤。这部分教程专注于如何设置和执行 repo 层的单元测试,确保与数据库的交互符合预期的业务逻辑。
a. 设置环境和初始化数据库连接
首先,需要确保测试环境能够正确连接到数据库。我们使用 sync.Once 确保数据库连接的初始化代码只执行一次,避免在多个测试中重复初始化。示例中的 getRepo 函数负责创建数据库连接,并初始化 repo 对象。
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 与返回的状态码,可以控制测试失败时的行为。
func TestMain(m *testing.M) {
err := getRepo()
if err != nil {
os.Exit(0)
}
code := m.Run()
os.Exit(code)
}
完整示例
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)
}
参考文章
- 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