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方法,供我们使用:

  1. All

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

    • 例子:

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

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

    • 例子:

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

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

    • 例子:

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

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

    • 例子:

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

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

    • 例子:

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

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

    • 例子:

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

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

    • 例子:

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

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

    • 例子:

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

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

    • 例子:

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

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

    • 例子:

      var s fmt.Stringer = &bytes.Buffer{}
      AssignableToTypeOf(s).Matches(time.Second) // 返回 false
  11. 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

最终效果:

image-20240419132959457

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)
}

参考文章

特别感谢:ChatGPT 4