《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 補?。∕onkey Patching)。Monkey 能夠在運行時動態替換函數或方法的實現,從而讓我們可以直接對結構體方法進行打樁,而無需額外抽象接口。當然,Monkey 的這種方式并不是沒有代價:它依賴底層的 unsafe 和 reflect 技術,雖然在測試階段能帶來極大便利,但在生產環境中需要謹慎使用。
?。?)為一個函數打樁
// 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() 一次性移除所有補?。ǖ鄶禃r候用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") } }
?。?)為一個方法打樁
假如有一個服務(如任務調度服務),不只跑一份,而是啟動了好幾個實例(進程),那么此時用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號