《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]