《Go 单元测试从入门到覆盖率提升》(三)
Go单元测试打桩框架
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 -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[0].(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[0].([]byte) ret1, _ := ret[1].(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[0].(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[0].(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 技术,虽然在测试阶段能带来极大便利,但在生产环境中需要谨慎使用。
(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
浙公网安备 33010602011771号