东新 发表于 2025-10-6 11:13:57

《Go 单元测试从入门到覆盖率提升》(三)

 Go单元测试打桩框架

  Golang有常用的三个打桩框架:GoStub、GoMock、Monkey。1、GoStub

   GoStub 是一款轻量级的单元测试框架,接口友好,使用方式简洁,能够覆盖多种常见测试场景:

[*]全局变量打桩:替换全局变量的值,方便测试不同状态下的逻辑。
[*]函数打桩:为函数设置自定义的返回结果,模拟不同输入输出。
[*]过程打桩:当函数没有返回值时(更像是过程),也可以通过打桩控制其行为。
[*]复合场景:可以将上述多种方式自由组合,满足更复杂的测试需求。
  凭借这些特性,GoStub 非常适合在需要灵活 Mock 的单元测试中使用,尤其是在快速验证逻辑、隔离外部依赖时效果明显。
GoStub安装:go get github.com/prashantv/gostub
  ① 为一个全局变量打桩(短暂修改这个变量的值)

var counter = 200

func TestStubExample(t *testing.T) {
    Convey("Simple Stub example", t, func() {
      // 验证初始值
      So(counter, ShouldEqual, 200)

      // 执行stub操作
      stubs := gostub.Stub(&counter, 100)
      defer stubs.Reset() // 确保最后能恢复

      // 验证stub后的值
      So(counter, should.Equal, 100) // 应该是100,不是200

      // 手动重置
      stubs.Reset()

      // 验证恢复后的值
      So(counter, ShouldEqual, 200)
    })
}
  ② 为一个函数打桩(让函数返回固定的值)

// GoStub/function_stub_test.go
package gostub

import (
    "testing"

    "github.com/prashantv/gostub"
    . "github.com/smartystreets/goconvey/convey"
)

// 给一个函数打桩
func GetCurrentTime() int {
    return 1000 // 模拟返回当前的时间戳
}

// 使用该函数的业务逻辑
func CalculateAge() int {
    birthTime := 500
    currentTime := GetCurrentTime()
    return currentTime - birthTime
}

// 用于打桩的函数变量
var getCurrentTimeFunc = GetCurrentTime

// 业务逻辑改写为使用函数变量
func CalculateAgeWithStub() int {
    birthTime := 500
    currentTime := getCurrentTimeFunc()
    return currentTime - birthTime
}

func TestFunctionStub(t *testing.T) {
    Convey("Function stub example", t, func() {
      // 正常情况下
      So(CalculateAgeWithStub(), ShouldEqual, 500)

      // 为函数打桩
      stubs := gostub.Stub(&getCurrentTimeFunc, func() int {
            return 2000 // 模拟不同的当前时间
      })
      defer stubs.Reset()

      // 验证打桩后的结果
      So(CalculateAgeWithStub(), ShouldEqual, 1500)

      // 恢复后再次验证
      stubs.Reset()
      So(CalculateAgeWithStub(), ShouldEqual, 500)
    })
}
  ③ 为一个过程打桩

  在 GoStub 中,除了对变量和有返回值的函数进行打桩外,还支持对 过程(Procedure) 进行打桩。所谓“过程”,就是 没有返回值的函数。在实际开发中,我们经常会把一些 资源清理、日志记录、状态更新 之类的逻辑写成过程函数。
  对过程打桩的意义在于:我们可以临时替换这些函数的行为,例如屏蔽真实的清理操作、只打印模拟日志,从而让测试更可控,不会影响外部环境。
// GoStub/simple_process_stub_test.go
package gostub

import (
    "testing"

    "github.com/prashantv/gostub"
    . "github.com/smartystreets/goconvey/convey"
)

// 要打桩的过程函数(无返回值)
func PrintLog(msg string) {
    println("Real log:", msg)
}

// 业务函数
func DoWork() {
    PrintLog("Starting work")
    // 做一些工作
    PrintLog("Work completed")
}

// 可打桩的函数变量
var printLogFunc = PrintLog

// 使用可打桩函数的业务版本
func DoWorkWithStub() {
    printLogFunc("Starting work")
    // 做一些工作
    printLogFunc("Work completed")
}

func TestProcessStub(t *testing.T) {
    Convey("Simple process stub example", t, func() {
      // 标记变量
      called := false

      // 为过程函数打桩
      stubs := gostub.Stub(&printLogFunc, func(msg string) {
            called = true
      })
      defer stubs.Reset()

      // 调用业务函数
      DoWorkWithStub()

      // 验证桩函数被调用了
      So(called, ShouldBeTrue)
    })
}
 
  ④ 复杂组合场景

// GoStub/multiple_stubs_test.go
package gostub

import (
    "testing"
    "github.com/prashantv/gostub"
    . "github.com/smartystreets/goconvey/convey"
)

var (
    name = "Alice"
    age= 25
)

func GetCity() string {
    return "Beijing"
}

var getCityFunc = GetCity

func GetUserInfo() string {
    return name + " is " + string(rune(age)) + " years old, lives in " + getCityFunc()
}

func TestMultipleStubs(t *testing.T) {
    Convey("Multiple stubs example", t, func() {
      // 使用一个stubs对象对多个目标打桩
      stubs := gostub.Stub(&name, "Bob")
      stubs.Stub(&age, 30)
      stubs.StubFunc(&getCityFunc, "Shanghai")
      defer stubs.Reset()
      
      // 验证所有桩都生效了
      So(GetUserInfo(), ShouldEqual, "Bob is 0 years old, lives in Shanghai")
    })
}
//这个例子同时对两个全局变量(name, age)和一个函数(getCityFunc)进行了打桩,使用同一个stubs对象管理,通过一次Reset()调用统一恢复。 
2、GoMock

   安装:
go get -u github.com/golang/mock/gomock
go get -u github.com/golang/mock/mockgen  在service层编写单元测试时,通常需要对repo层进行mock。这是为了确保你的测试只关注service层本身的逻辑,而不是它所以来的外部组件(如数据库、网络等)。
   ① 定义一个接口

package db

type Repository interface {
    Create(key string, value []byte) error
    Retrieve(key string) ([]byte, error)
    Update(key string, value []byte) error
    Delete(key string) error
}  ② 生成mock类文件

  mockgen是GoMock提供的一个命令行工具,用来读取接口定义,然后生成对应的mock文件。它有两种模式:

[*]源文件模式(最常用)
mockgen -source=./infra/db.go -destination=./mock/mock_repository.go -package=mock
//去db.go找接口
//去mock目录下生成mock_repository.go
//生成的包名叫mock

[*]反射模式
mockgen database/sql/driver Conn,Driver
// 表示要对database/sql/driver 包下的Conn和Driver接口生成mock  接下来就可以生成mock_repository.go文件了,这是mockgen自动生成的,包含两部分:
// Automatically generated by MockGen. DO NOT EDIT!
// Source: ./infra/db.go (interfaces: Repository)

package mock

import (
    gomock "github.com/golang/mock/gomock"
)

// MockRepository is a mock of Repository interface
type MockRepository struct {
    ctrl   *gomock.Controller
    recorder *MockRepositoryMockRecorder
}

// MockRepositoryMockRecorder is the mock recorder for MockRepository
type MockRepositoryMockRecorder struct {
    mock *MockRepository
}

// NewMockRepository creates a new mock instance
func NewMockRepository(ctrl *gomock.Controller) *MockRepository {
    mock := &MockRepository{ctrl: ctrl}
    mock.recorder = &MockRepositoryMockRecorder{mock}
    return mock
}

// EXPECT returns an object that allows the caller to indicate expected use
func (m *MockRepository) EXPECT() *MockRepositoryMockRecorder {
    return m.recorder
}

// Create mocks base method
func (m *MockRepository) Create(key string, value []byte) error {
    ret := m.ctrl.Call(m, "Create", key, value)
    ret0, _ := ret.(error)
    return ret0
}

// Create indicates an expected call of Create
func (mr *MockRepositoryMockRecorder) Create(key, value interface{}) *gomock.Call {
   return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockRepository)(nil).Create), key, value)
}

// Retrieve mocks base method
func (m *MockRepository) Retrieve(key string) ([]byte, error) {
   ret := m.ctrl.Call(m, "Retrieve", key)
   ret0, _ := ret.([]byte)
   ret1, _ := ret.(error)
   return ret0, ret1
}

// Retrieve indicates an expected call of Retrieve
func (mr *MockRepositoryMockRecorder) Retrieve(key interface{}) *gomock.Call {
   return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Retrieve", reflect.TypeOf((*MockRepository)(nil).Retrieve), key)
}

// Update mocks base method
func (m *MockRepository) Update(key string, value []byte) error {
   ret := m.ctrl.Call(m, "Update", key, value)
   ret0, _ := ret.(error)
   return ret0
}

// Update indicates an expected call of Update
func (mr *MockRepositoryMockRecorder) Update(key, value interface{}) *gomock.Call {
   return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockRepository)(nil).Update), key, value)
}

// Delete mocks base method
func (m *MockRepository) Delete(key string) error {
   ret := m.ctrl.Call(m, "Delete", key)
   ret0, _ := ret.(error)
   return ret0
}

// Delete indicates an expected call of Delete
func (mr *MockRepositoryMockRecorder) Delete(key interface{}) *gomock.Call {
   return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockRepository)(nil).Delete), key)
}  MYSQL.go编写

package MYSQL

import db "GoExample/GoMock/infra"

type MYSQL struct {
    DB db.Repository
}

func NewMySQL(db db.Repository) *MYSQL {
    return &MySQL{DB: db}
}

func (mysql *MySQL) CreateData(key string, value []byte) error {
    return mysql.DB.Retrieve(key, value)
}

func (mysql *MySQL) GetData(key string) ([]byte, error) {
   return mysql.DB.Retrieve(key)
}

func (mysql *MySQL) DeleteData(key string) error {
   return mysql.DB.Delete(key)
}

func (mysql *MySQL) UpdateData(key string, value []byte) error {
   return mysql.DB.Update(key, value)
} 测试用例MYSQL_test.go编写

package MYSQL

import (
    "testing"
    "GoExample/GoMock/mock"
    "fmt"
    "github.com/golang/mock/gomock"
)

func TestMYSQL_CreateData(t *testing.T) {
    // 1.创建gomock控制器
    // 定义了mock对象的作用域和生命周期,以及期望
    ctr := gomock.NewController(t)
    //2. 结束时检查期望有没有满足
    defer ctr.Finish()

    key := "Hello"
    value := []byte("Go")

    //3.生成一个假的数据库对象
    mockRepo := mock_db.NewMockRepository(ctrl)
    //4.设定期望:若调用Create(”Hello", "Go"), 就返回nil
    mockRepo.EXPECT().Create(key, value).Return(nil)
    //5. 将假的repo对象注入到mySQL对象中(后续需要通过mySQL调用绑定的方法)
    mySQL := NewMYSQL(mockRepo)
    //6. 调用CreateData, 会转发到mockRepo.Create
    err := mySQL.CreateData(key, value)

    if err != nil {
      //7.正常情况下不会打印,因为 err 应该是 nil
      fmt.Println(err)
    }
}

func TestMySQL_GetData(t *testing.T) {
    ctr := gomock.NewController(t)
    defer ctr.Finish()
    key := "Hello"
    value := []byte("Go")
    mockRepo := mock_db.NewMockRepository(ctr)
    // InOrder是期望下面的方法按顺序调用,若调用顺序不一致,就会触发测试失败
    gomock.InOrder(
      mockRepo.EXPECT().Retrieve(key).Return(value, nil),
    )
    mySQL := NewMySQL(mockRepo)
    bytes, err := mySQL.GetData(key)
    if err != nil {
      fmt.Println(err)
    } else {
      fmt.Println(string(bytes))
    }
}

func TestMySQL_UpdateData(t *testing.T) {
    ctr := gomock.NewController(t)
    defer ctr.Finish()
    var key string = "Hello"
    var value []byte = []byte("Go")
    mockRepository := mock_db.NewMockRepository(ctr)
    gomock.InOrder(
       mockRepository.EXPECT().Update(key, value).Return(nil),
    )
    mySQL := NewMySQL(mockRepository)
    err := mySQL.UpdateData(key, value)
    if err != nil {
      fmt.Println(err)
    }
}

func TestMySQL_DeleteData(t *testing.T) {
    ctr := gomock.NewController(t)
    defer ctr.Finish()
    var key string = "Hello"
    mockRepository := mock_db.NewMockRepository(ctr)
    gomock.InOrder(
      mockRepository.EXPECT().Delete(key).Return(nil),
    )
    mySQL := NewMySQL(mockRepository)
    err := mySQL.DeleteData(key)
    if err != nil {
      fmt.Println(err)
    }
}3、Monkey

   前面提到,GoStub 非常适合处理全局变量、函数和过程的打桩,配合 GoMock 还能完成接口的替换。但是当我们遇到 结构体方法 时,问题就变得棘手了。
  在 Go 语言里,方法是与结构体绑定的,像 srv.GetUser(1) 这种调用,GoStub 并不能直接替换。如果项目里大量使用 面向对象风格(struct + 方法),就不得不额外抽象出接口,再通过接口去 mock。这种做法虽然可行,但会让测试代码和业务代码之间产生一定的“距离”,降低了测试的直观性和灵活性。
  为了填补这一空白,就有了另一类更“激进”的工具 —— Monkey 补丁(Monkey Patching)。Monkey 能够在运行时动态替换函数或方法的实现,从而让我们可以直接对结构体方法进行打桩,而无需额外抽象接口。当然,Monkey 的这种方式并不是没有代价:它依赖底层的 unsafe 和 reflect 技术,虽然在测试阶段能带来极大便利,但在生产环境中需要谨慎使用。
  安装命令:go get github.com/bouk/monkey   (1)为一个函数打桩

// Exec是infra层的一个操作函数:
func Exec(cmd string, args ...string) (string, error) {
    cmdpath, err := exec.LookPath(cmd)
    if err != nil {
      fmt.Errorf("exec.LookPath err: %v, cmd: %s", err, cmd)
      return "", infra.ErrExecLookPathFailed
    }

    var output []byte
    output, err = exec.Command(cmdpath, args...).CombinedOutput()
    if err != nil {
      fmt.Errorf("exec.Command.CombinedOutput err: %v, cmd: %s", err, cmd)
      return "", infra.ErrExecCombinedOutputFailed
    }
    fmt.Println("CMD[", cmdpath, "]ARGS[", args, "]OUT[", string(output), "]")
    return string(output), nil
}
// 在这个函数中调用了库函数exec.LoopPath和exec.Command,因此Exec函数的返回值和运行时
// 的底层环境密切相关。若在被测函数中调用了Exec函数,应该根据用例的场景对Exec函数打桩
// 具体的意思就是,打桩的是依赖,里面调用的两个库函数是依赖 
import (
    "testing"
    . "github.com/smartystreets/goconvey/convey"
    . "github.com/bouk/monkey"
    "infra/osencap"
)

const any = "any"

func TestExec(t *testing.T) {
    Convey("test has digit", t, func() {
      Convey("for succ", func() {
            outputExpect := "xxx-vethName100-yyy"
            // 运行时打桩,将进程内所有对osencap.Exec的调用,都跳转到这个匿名函数上
            guard := Patch(
             osencap.Exec,
             func(_ string, _ ...string) (string, error) {
               return outputExpect, nil
             })
            defer guard.Unpatch()
            output, err := osencap.Exec(any, any)
            So(output, ShouldEqual, outputExpect)
            So(err, ShouldBeNil)
      })
    })
}
// Patch的第一个参数是:要被替换的目标函数”的函数标识符
// 第二个参数是:替身函数,一般写成匿名函数
// guard.Unpatch() 取消本次补丁,恢复原实现
// UnpatchAll() 一次性移除所有补丁(但多数时候用defer guard.Unpatch()更安全)注意:

[*]Monkey在进程级生效,并发/并行的用例可能互相影响
[*]这个补丁对进程内所有调用点生效,所以务必defer
   (2)为一个过程打桩

// 当一个函数没有返回值时,该函数一般称为过程。
func TestDestroyResource(t *testing.T) {
    called := false
    guard := Patch(DestroyResource, func(_ string) {
      called = true
    })
    defer guard.Unpatch()

    DestroyResource("abc") // 实际不会执行原逻辑

    if !called {
      t.Errorf("expected patched DestroyResource to be called")
    }
}  (3)为一个方法打桩

  假如有一个服务(如任务调度服务),不只跑一份,而是启动了好几个实例(进程),那么此时用Etcd做选举,选出一个“Master”。Master负责把所有任务分配给各个实例,然后把分配的结果写到Etcd。剩下的实例Node通过Watch功能实时监听Etcd的任务分配结果,收到任务列表后,每个实例根据自己的instanceId过滤,只挑出属于自己的任务去执行。
  现在我们需要给Etcd.Get()方法打桩,使得每个实例在输入自己的instanceId时,会返回固定的任务列表。
func (e *Etcd) Get(instanceId string) []string {
    // 本来这里应该去 Etcd 拿属于 instanceId 的任务
    return []string{} // 真实情况依赖外部环境
}

var e *Etcd      //只是声明一个指针变量,不需要真正赋值
guard := PatchInstanceMethod(
reflect.TypeOf(e),      // 表示etcd类型的方法
"Get",                  //方法名
func(_ *Etcd, _ string) []string {      //替身函数(签名要一致)
return []string{"task1", "task5", "task8"}
})
defer guard.Unpatch()  (4)任意相同或不同的基本场景组合

Px1
defer UnpatchAll()
Px2
...
Pxn  (5)桩中桩的一个案例

type Movie strcut {
    Name string
    Type string
    Score int
}

//定义一个interface类型
type Repository struct {
    // 传进去一个空指针movie,希望返回的时候把movie填上内容,然后返回error
    // 但是真实的Retrieve要连数据库,太重了。用GoMock虽然能拦截调用,但GoMock只能决定返回值
    // (比如只能返回nil),却不能真正往movie里面填数据
    Retrieve(key string, movie *movie) error
}   

// ---------------------------------------------------------
func TestDemo(t *testing.T) {
    Convey("test demo", t, func() {
      Convey("retrieve movie", func() {
            ctrl := NewController(t)
            defer ctrl.Finish()
            mockRepo := mock_db.NewMockRepository(ctrl)
            mockRepo.EXPECT().Retrieve(Any(), Any()).Return(nil)
            Patch(redisrepo.GetInstance, func() Repository {
                return mockRepo
            })
            defer UnpatchAll()
            PatchInstanceMethod(reflect.TypeOf(mockRepo), "Retrieve", func(_ *mock_db.MockRepository, name string, movie *Movie) error {
                movie = &Movie{Name: name, Type: "Love", Score: 95}
                return nil
            })
            repo := redisrepo.GetInstance()
            var movie *Movie
            err := repo.Retrieve("Titanic", movie)
            So(err, ShouldBeNil)
            So(movie.Name, ShouldEqual, "Titanic")
            So(movie.Type, ShouldEqual, "Love")
            So(movie.Score, ShouldEqual, 95)
      })
      ...
    })
}桩中桩的做法:

[*]第一层:GoMock,把Retrieve换成假的实现,让它在调用时不会连接数据库,但只能返回nil,没法改movie
[*]第二层:Monkey Patch,把这个假的Retrieve方法本身替换掉。写一个补丁函数,在里面手动改movie的值。
整个流程:
- 程序里调用repo.Retrieve( "Titanic", movie)
- 实际走到GoMock的桩:但GoMock的桩又被Monkey Patch替换了
- 最终执行的是你写的补丁函数,它把movie填好,并返回nil
- 测试代码断言movie的值是否符合预期

为什么不能只用Monkey?
- GoMock能mock接口,管理调用次数、顺序,返回error
- 只用Monkey 的话,不能校验Retrieve方法到底被调用了几次,参数是不是对的。
mockRepo.EXPECT().Retrieve("Titanic", gomock.Any()).Return(nil).Times(2) 这里Times的意思是必须调用2次4、HTTPTest

https://pkg.go.dev/net/http/httptest
  由于 Go 标准库的强大支持,Go 可以很容易的进行 Web 开发。为此,Go 标准库专门提供了 net/http/httptest 包专门用于进行 http Web 开发测试。
var GetUserHost = "https://account.wps.cn"
// 默认情况下,GetUser会调用https://account.wps.cn/p/auth/check这个真实的接口
// 但在测试时不想依赖外部网络,所以要“伪造”一个接口服务器
func GetUser(wpssid, xff string) *User {
    url := fmt.Sprintf("%s/p/auth/check", GetUserHost)
    user := client.POST(url, ...)
    return user
}

func TestGetUser(t *testing.T) {
    // 用httptest.NewServer启动了一个本地HTTP服务器
    // 它只实现一个接口POST /p/auth/check,并且返回一个固定的JSON(模拟线上接口的返回)
    svr := httptest.NewServer(func () http.HandlerFunc {
      r := gin.Default()
      r.POST("/p/auth/check", func(c *gin.Context) {
            c.JSON(http.StatusOK, gin.H{
                "result":      "ok",
                "companyid":   1,
                "nickname":    "test-account",
                "account":   "123***789@test.com",
                "phonenumber": "123***789",
                "pic":         "https://pic.test.com",
                "status":      "active",
                "userid":      currentUserID,
            })
      })

      return r.ServeHTTP
    })

    defer svr.Close()    //测试结束后关闭这个临时服务器
    GetUserHost = svr.URL

    user := GetUser("test-wps-id", "")
    ...
}5、如何理解Golang测试框架和打桩框架的关系

测试框架是骨架:提供运行环境+断言机制
打桩框架是工具:帮你在测试环境中模拟依赖,制造可控场景
它们是配合关系,而不是互相替代。
若没有测试框架,写了桩也没地方运行。
若没有打桩框架,你测试代码可能跑不了(真实依赖很复杂) 
覆盖率

1、单元测试执行

# 匹配当前目录下*_test.go命令的文件,执行每一个测试函数
go test -v

# 执行 calc_test.go 文件中的所有测试函数
go test -v calc_test.go calc.go

# 指定特定的测试函数(其中:-count=1用于跳过缓存)
go test -v -run TestAdd calc_test.go calc.go -count=1

#调试单元测试文件。运行命令时,当前目录应为项目根目录。
go test ./...   #运行所有包单元测试文件
go test ${包名}   #运行指定包的单元测试文件
go test ${指定路径}    #运行指定路径的单元测试文件 
2、生成单元测试覆盖率

go test -v -covermode=set -coverprofile=cover.out -coverpkg=./...

其中,
-covermode 有三种模式:
• set 语句是否执行,默认模式
• count 每个语句执行次数
• atomic 类似于count,表示并发程序中的精确技术
-coverprofile是统计信息保存的文件。 
3、查看单元测试覆盖率

//(1)查看每个函数的覆盖情况
go tool cover -func=cover.out

//(2)使用网页方式
go tool cover -html=cover.out 
 
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
页: [1]
查看完整版本: 《Go 单元测试从入门到覆盖率提升》(三)