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[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 技术,虽然在测试阶段能带来极大便利,但在生产环境中需要谨慎使用。
安装命令: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
复制代码
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |