xgo: 一款新鮮出爐的 Go 代碼測試?yán)?/h1>共
16829字,需瀏覽
34分鐘
·
2024-05-23 08:00
大家好,我是江湖十年。
我曾經(jīng)寫過一篇文章《測試代碼終極解決方案 Monkey Patching》,里面介紹了 Go 語言中的猴子補(bǔ)丁方案。如今,時隔數(shù)月我又發(fā)現(xiàn)了一款新的工具可以實現(xiàn) Monkey Patching,本文將帶大家一起嘗鮮下這款新的測試工具表現(xiàn)如何。
簡介
簡單一句話介紹 xgo:它是一款強(qiáng)大的的 Go 測試工具集,功能包括 Trap、Mock、Trace、增量覆蓋率。
當(dāng)然,開發(fā)中最常用的還是 Mock 功能,也是本文講解的重點(不要慌,其他功能也會介紹)。
以下是 xgo 支持的所有平臺:
x86
x86_64 (amd64)
arm64
any other Arch...
Linux
Y
Y
Y
Y
Windows
Y
Y
Y
Y
macOS
Y
Y
Y
Y
any other OS...
Y
Y
Y
Y
可以發(fā)現(xiàn),xgo 支持所有 go 語言支持的 OS 和 Arch,即它是跨平臺的。
跨平臺這一點是最吸引我的地方,也是能讓 xgo 脫穎而出的關(guān)鍵。
此外,xgo 還是并發(fā)安全的 Monkey Patching 方案,這點也是有別于其他方案的一個亮點。
本文就以測試一個 HTTP 服務(wù)程序來演示 xgo 的基本使用。
HTTP 服務(wù)程序示例
假設(shè)我們有一個 HTTP 服務(wù)程序?qū)ν馓峁┯脩舴?wù),代碼如下:
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"github.com/julienschmidt/httprouter"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
type User struct {
ID int
Name string
}
func NewMySQLDB(host, port, user, pass, dbname string) (*gorm.DB, error) {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
user, pass, host, port, dbname)
return gorm.Open(mysql.Open(dsn), &gorm.Config{})
}
func NewUserHandler(store *gorm.DB) *UserHandler {
return &UserHandler{store: store}
}
type UserHandler struct {
store *gorm.DB
}
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
w.Header().Set("Content-Type", "application/json")
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = fmt.Fprintf(w, `{"msg":"%s"}`, err.Error())
return
}
defer func() { _ = r.Body.Close() }()
u := User{}
if err := json.Unmarshal(body, &u); err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = fmt.Fprintf(w, `{"msg":"%s"}`, err.Error())
return
}
if err := h.store.Create(&u).Error; err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprintf(w, `{"msg":"%s"}`, err.Error())
return
}
w.WriteHeader(http.StatusCreated)
}
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
id := ps[0].Value
uid, _ := strconv.Atoi(id)
w.Header().Set("Content-Type", "application/json")
var u User
if err := h.store.First(&u, uid).Error; err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprintf(w, `{"msg":"%s"}`, err.Error())
return
}
_, _ = fmt.Fprintf(w, `{"id":%d,"name":"%s"}`, u.ID, u.Name)
}
func setupRouter(handler *UserHandler) *httprouter.Router {
router := httprouter.New()
router.POST("/users", handler.CreateUser)
router.GET("/users/:id", handler.GetUser)
return router
}
func main() {
mysqlDB, _ := NewMySQLDB("localhost", "3306", "user", "password", "test")
handler := NewUserHandler(mysqlDB)
router := setupRouter(handler)
_ = http.ListenAndServe(":8000", router)
}
這是一個簡單的 Web Server 程序,服務(wù)監(jiān)聽 8000 端口,提供了兩個接口:
POST /users 用來創(chuàng)建用戶。
GET /users/:id 用來查詢指定 ID 對應(yīng)的用戶信息。
代碼邏輯比較簡單,我就不詳細(xì)講解了。
為了保證業(yè)務(wù)的正確性,我們應(yīng)該對 (*UserHandler).CreateUser 和 (*UserHandler).GetUser 這兩個 Handler 方法進(jìn)行單元測試。
使用 xgo 進(jìn)行單元測試
安裝
xgo 使用前必須通過 go install 命令進(jìn)行安裝:
$ go install github.com/xhd2015/xgo/cmd/xgo@latest
$ xgo version
1.0.35
編寫測試代碼
以 (*UserHandler).CreateUser 方法為例演習(xí)下如何編寫測試代碼。
我們先來分析下這個方法的依賴項:
首先UserHandler 這個結(jié)構(gòu)體本身有一個 store 屬性,依賴了 *gorm.DB 對象。
其次,CreateUser 方法還接收三個參數(shù),它們都屬于 HTTP 網(wǎng)絡(luò)相關(guān)的外部依賴,你可以在我的另一篇文章《在 Go 語言單元測試中如何解決 HTTP 網(wǎng)絡(luò)依賴問題》中找到解決方案,就不在本文中進(jìn)行講解了。
所以,我們應(yīng)該要想辦法解決 *gorm.DB 這個外部依賴。
由于我們編寫代碼時,沒有為支持單元測試而專門使用接口來進(jìn)行解耦,導(dǎo)致 UserHandler 結(jié)構(gòu)體直接依賴了 *gorm.DB 結(jié)構(gòu)體對象,無法使用 gomock 工具對依賴項進(jìn)行 Mock。
在不改變代碼的前提下,我們可以使用 xgo 提供的 Monkey Patching 技術(shù)為依賴對象 *gorm.DB 打上猴子補(bǔ)丁,以此來解決測試代碼中難以調(diào)用 h.store.First(&u, uid).Error 方法問題。
要使用 xgo 編寫測試,需要引入 xgo 提供的 runtime 包,所以先使用 go get 命令將其添加到 go.mod 依賴項:
go get github.com/xhd2015/xgo/runtime@latest
使用 xgo 為 (*UserHandler).CreateUser 方法編寫的測試代碼如下:
package main
import (
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/xhd2015/xgo/runtime/mock"
"gorm.io/gorm"
)
func TestUserHandler_CreateUser(t *testing.T) {
mysqlDB := &gorm.DB{}
handler := NewUserHandler(mysqlDB)
router := setupRouter(handler)
// 為 mysqlDB 打上猴子補(bǔ)丁,替換其 Create 方法
mock.Patch(mysqlDB.Create, func(value interface{}) (tx *gorm.DB) {
expected := &User{
Name: "user1",
}
actual := value.(*User)
assert.Equal(t, expected, actual)
return mysqlDB
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/users", strings.NewReader(`{"name": "user1"}`))
router.ServeHTTP(w, req)
// 斷言成功響應(yīng)
assert.Equal(t, 201, w.Code)
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
assert.Equal(t, "", w.Body.String())
}
我們使用 xgo 提供的 mock.Patch 方法,為 mysqlDB 對象的 Create 方法打了一個猴子補(bǔ)丁,然后使用匿名函數(shù)來實現(xiàn)這個 Create 方法,并且,在匿名函數(shù)的內(nèi)部還對 Create 方法接收到的參數(shù)進(jìn)行了驗證。
沒錯,xgo 使用起來就是這么簡單,這也體現(xiàn)了猴子補(bǔ)丁的強(qiáng)大,它能原地修改 mysqlDB.Create 方法的實現(xiàn)。
這樣,在執(zhí)行測試代碼時,測試方法將不再執(zhí)行 mysqlDB.Create 原方法內(nèi)部邏輯,而會被替換為調(diào)用在此定義的匿名函數(shù)邏輯。
要執(zhí)行測試,我們不能像原來一樣使用 go test 來執(zhí)行測試函數(shù),需要將 go 命令替換為 xgo 命令:
$ xgo test -v -run TestUserHandler_CreateUser
=== RUN TestUserHandler_CreateUser
--- PASS: TestUserHandler_CreateUser (0.00s)
PASS
ok github.com/jianghushinian/blog-go-example/test/xgo 0.524s
測試通過。
xgo 用法跟普通的 go test 用法完全相同,這也大大簡化了我們切換命令的心智負(fù)擔(dān),幾乎零成本切換。
NOTE: 如果直接使用 go test -v -run TestUserHandler_CreateUser 執(zhí)行測試將得到報錯,讀者可自行測試。
接下來我們再為 (*UserHandler).GetUser 方法編寫如下測試代碼:
func TestUserHandler_GetUser(t *testing.T) {
mysqlDB := &gorm.DB{}
handler := NewUserHandler(mysqlDB)
router := setupRouter(handler)
// 為 mysqlDB 打上猴子補(bǔ)丁,替換其 First 方法
mock.Patch(mysqlDB.First, func(dest interface{}, conds ...interface{}) (tx *gorm.DB) {
assert.Equal(t, dest, &User{})
assert.Equal(t, len(conds), 1)
assert.Equal(t, conds[0], 1)
u := dest.(*User)
u.ID = 1
u.Name = "user1"
return mysqlDB
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/users/1", nil)
router.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
assert.Equal(t, `{"id":1,"name":"user1"}`, w.Body.String())
}
與之前的套路如出一轍,使用 xgo 執(zhí)行測試:
$ xgo test -v -run TestUserHandler_GetUser
=== RUN TestUserHandler_GetUser
--- PASS: TestUserHandler_GetUser (0.00s)
PASS
ok github.com/jianghushinian/blog-go-example/test/xgo 0.424s
測試通過。
現(xiàn)在,你也許會問,這種使用 mock.Patch 打過猴子補(bǔ)丁的測試代碼需要使用 xgo 才能執(zhí)行,那沒有用到 mock.Patch 的普通測試代碼能不能也用 xgo 執(zhí)行呢?答案是肯定的。
比如我們隨意寫一個沒什么意義的 demo 測試:
func TestDemo(t *testing.T) {
t.Log("---------- TestDemo ----------")
}
使用 xgo 執(zhí)行測試代碼:
$ xgo test -v -run TestDemo
=== RUN TestDemo
main_test.go:65: ---------- TestDemo ----------
--- PASS: TestDemo (0.00s)
PASS
ok github.com/jianghushinian/blog-go-example/test/xgo 0.219s
測試通過。
使用 xgo 一次執(zhí)行全部測試代碼:
$ xgo test -v
=== RUN TestUserHandler_CreateUser
--- PASS: TestUserHandler_CreateUser (0.00s)
=== RUN TestUserHandler_GetUser
--- PASS: TestUserHandler_GetUser (0.00s)
=== RUN TestDemo
main_test.go:65: ---------- TestDemo ----------
--- PASS: TestDemo (0.00s)
PASS
ok github.com/jianghushinian/blog-go-example/test/xgo 0.175s
測試通過。
這樣我們就統(tǒng)一了執(zhí)行測試代碼的方式,所有測試都可以使用 xgo 來執(zhí)行。理論上,我們只需要將現(xiàn)有項目執(zhí)行 go test 的地方,替換成 xgo test 即可兼容所有測試代碼,這大大降低了引入 xgo 的遷移成本。
xgo 其他功能
前文提到,xgo 核心功能包括 Trap、Mock、Trace、增量覆蓋率。
其實我們上面介紹的 mock.Patch 即為 Mock 功能,不過除了這個 API,xgo 還提供了另外一個 Mock API mock.Mock,實際上這兩個方法底層調(diào)用的是同一個函數(shù),用法也類似,我就不進(jìn)行演示了,感興趣的讀者可以深入源碼進(jìn)行研究。
接下來我將依次介紹下 Trap、Trace、增量覆蓋率這幾個功能。
Trap
Trap 是 xgo 的核心,也是 Mock、Trace 功能的基礎(chǔ),它可以對 Go 函數(shù)進(jìn)行攔截。
以下是一個官方文檔中使用 Trap 的例子:
package main
import (
"context"
"fmt"
"github.com/xhd2015/xgo/runtime/core"
"github.com/xhd2015/xgo/runtime/trap"
)
func init() {
trap.AddInterceptor(&trap.Interceptor{
Pre: func(ctx context.Context, f *core.FuncInfo, args core.Object, results core.Object) (interface{}, error) {
if f.Name == "A" {
fmt.Printf("trap A\n")
return nil, nil
}
if f.Name == "B" {
fmt.Printf("abort B\n")
return nil, trap.ErrAbort
}
return nil, nil
},
})
}
func main() {
A()
B()
}
func A() {
fmt.Printf("A\n")
}
func B() {
fmt.Printf("B\n")
}
使用 go 命令執(zhí)行代碼:
$ go run main.go
A
B
代碼正常執(zhí)行。
如果改為使用 xgo 執(zhí)行代碼;
xgo run main.go
trap A
A
abort B
可以發(fā)現(xiàn),xgo 改變了代碼執(zhí)行結(jié)果,這就是 Trap 的強(qiáng)大之處,xgo 攔截了原有代碼的邏輯,進(jìn)而執(zhí)行攔截器內(nèi)部的邏輯。
不過這種用法并不太多,我們更多的場景還是使用更上層的 Mock 功能來編寫測試代碼。
Trace
Trace 功能可以將 Go 程序執(zhí)行過程可視化,在一定程度上可以替代 Debug 工具,方便我們以可視化的方式進(jìn)行代碼調(diào)試。
要想使用 Trace 功能,也很簡單,僅需要在使用 xgo 執(zhí)行測試代碼時加上 --strace 標(biāo)志:
$ xgo test -v -run TestDemo --strace
=== RUN TestDemo
main_test.go:65: ---------- TestDemo ----------
--- PASS: TestDemo (0.00s)
PASS
ok github.com/jianghushinian/blog-go-example/test/xgo 0.162s
執(zhí)行以上命令會在當(dāng)前目錄生成一個 TestDemo.json 文件,文件中即為可視化所需報告數(shù)據(jù)。
接下來執(zhí)行如下命令即可開啟 Trace 可視化服務(wù):
$ xgo tool trace TestDemo.json
Server listen at http://localhost:7070
此時會自動打開瀏覽器顯示類似如下頁面:
Trace Demo
左側(cè)列表可視化的展示了堆棧跟蹤信息,每項前面如果是藍(lán)色表示被調(diào)用函數(shù)正常返回,紅色表示返回錯誤。如果你使用 VSCode 開發(fā)代碼的話,點擊 VSCode 圖標(biāo)還會自動定位到 VSCode 中函數(shù)定義的位置,方便排查問題。
遺憾的是,經(jīng)過筆者實測目前此功能還不夠穩(wěn)定,存在影響使用的 BUG,甚至經(jīng)常超時無法生成 Trace 文件。
增量覆蓋率
我們要介紹的最后一個功能是增量覆蓋率。
go test 本身支持測試覆蓋率,不過 xgo 更近一步,它可以根據(jù) Git 變更,計算出增量測試覆蓋率,極大方便了代碼 review 的過程。
為了查看變更代碼的增量覆蓋率,我們對 GetUser 方法代碼進(jìn)行了如下修改:

git diff 命令輸出
使用 xgo 命令輸出測試覆蓋率文件:
$ xgo test -v -coverpkg . -coverprofile cover.out
=== RUN TestUserHandler_CreateUser
--- PASS: TestUserHandler_CreateUser (0.00s)
=== RUN TestUserHandler_GetUser
true...
--- PASS: TestUserHandler_GetUser (0.00s)
=== RUN TestDemo
main_test.go:65: ---------- TestDemo ----------
--- PASS: TestDemo (0.00s)
PASS
coverage: 54.8% of statements in .
ok github.com/jianghushinian/blog-go-example/test/xgo 0.962s
NOTE: 由于此功能基于 Git,所以如果代碼不在 Git 倉庫,則執(zhí)行命令會報錯。并且筆者實測,如果一個 Git 倉庫存在多個項目情況下,執(zhí)行命令也會報錯。
得到測試覆蓋率文件 cover.out 后,執(zhí)行以下命令啟動一個本地 Server 來展示測試覆蓋率:
$ xgo tool coverage serve cover.out
執(zhí)行命令后,xgo 會自動開啟瀏覽器并訪問 http://localhost:8000 地址:

Incremental Coverage
默認(rèn)展示的就是增量代碼測試覆蓋率。藍(lán)色表示已覆蓋,黃色表示未覆蓋,展示結(jié)果符合預(yù)期。
我們也可以切換成查看全局代碼測試覆蓋率:

Full Coverage
以上就是 xgo 對增量測試覆蓋率的支持,還是能夠比較方便查看增量代碼測試覆蓋率的。
總結(jié)
xgo 作為一款 Monkey Patching 解決方案的工具,其支持 Trap、Mock、Trace、增量覆蓋率幾個功能,方便我們用來編寫單元測試。
Trap 是 xgo 的核心,雖然不太常用,但上層的 Mock 和 Trace 都是基于 Trap 實現(xiàn)的。
Mock 是我們用的最多的功能,其可以實現(xiàn)跨平臺的 Monkey Patching 解決方案。
Trace 功能可以方便我們以可視化的形式對代碼進(jìn)行 Debug。
而增量覆蓋率則可以方便我們在 review 代碼時可視化的感知到增量代碼的測試情況。
總結(jié)下 xgo 目前的優(yōu)點和不足:
優(yōu)點:
-
相比于其他 Monkey Patching 解決方案,xgo 的跨平臺支持最好,這也是我認(rèn)為 xgo 最大的優(yōu)勢。
-
并發(fā)安全,多個協(xié)程下 Mock 完全隔離。
-
相比于 gomonkey 等,xgo 在 Patch 后不需要進(jìn)行 Reset 操作對猴子補(bǔ)丁進(jìn)行恢復(fù)。
-
使用方便,僅需要將 go 命令替換成 xgo 即可。
不足:
-
雖然使用時僅需將 go 命令替換成 xgo 即可,但這也意味著需要單獨安裝 xgo 才行,原來的 go test 命令執(zhí)行會報錯,不夠友好。
-
不能隨意升級 Go 版本,至少目前筆者測試結(jié)果是這樣,我在使用 go1.22.0 時有些命令會報錯,換成 go1.21.0 則沒有問題。
-
Trace 功能速度比較慢,且存在 BUG。
-
沒有提供 --help 命令,較為遺憾。
以上優(yōu)點和不足,都是我個人基于當(dāng)前版本測試下來的主觀使用體驗,希望 xgo 能夠盡快發(fā)展起來,補(bǔ)齊短板。
如果,你想了解 xgo 的誕生以及實現(xiàn)方案,前幾天 xgo 作者在 Go 夜讀分享了Go 夜讀第 151 期:xgo: 基于編譯期代碼重寫實現(xiàn) Mock 和 Trace: https://talkgo.org/t/topic/5514。
本文完整代碼示例我放在了 GitHub 上,歡迎點擊查看。
希望此文能對你有所幫助。
P.S.
本來預(yù)計這段時間不會再寫 Go 測試相關(guān)文章了,因為之前寫的測試相關(guān)文章已經(jīng)覆蓋了大部分日常編寫單元測試的場景。不過前段時間 xgo 作者聯(lián)系到我,跟我分享了 xgo 項目,解決了 gomonkey 項目的兼容性和并發(fā)問題。
因為深入研究 Go 語言 Monkey Patching 解決方案這個方向的人很少,所以我對這個項目還是比較感興趣的,于是花時間體驗了下,便有了此文。
xgo 項目給在 Go 語言中單元測試帶來了新的可能性,是目前我體驗過的最方便也是兼容性最好的 Monkey Patching 方案。
誠然,xgo 項目還不夠成熟,它還非常年輕,剛開源出來不久,但是它開了個好頭,期待給它足夠的時間,能夠成長為 Go 社區(qū)里 Monkey Patch 解決方案中最為流行的項目之一。
參考
-
xgo 源碼:https://github.com/xhd2015/xgo
-
xgo: 在go中使用-toolexec實現(xiàn)猴子補(bǔ)丁:https://blog.xhd2015.xyz/zh/posts/xgo-monkey-patching-in-go-using-toolexec/
-
xgo trace: 一個強(qiáng)大的Go堆??梢暬ぞ撸篽ttps://blog.xhd2015.xyz/zh/posts/xgo-trace_a-powerful-visualization-tool-in-go/
-
Go 夜讀第 151 期:xgo: 基于編譯期代碼重寫實現(xiàn) Mock 和 Trace:https://talkgo.org/t/topic/5514
-
本文 GitHub 示例代碼:https://github.com/jianghushinian/blog-go-example/tree/main/test/xgo
推薦閱讀:
Go 1.22 的新增功能系列之三:slices.Concat
6 個必須嘗試的將代碼轉(zhuǎn)換為引人注目的圖表的工具
Go 1.22 的新增功能系列之二:reflect.TypeFor
Go早期是如何在Google內(nèi)部發(fā)展起來的
Go區(qū)不大,創(chuàng)造神話,科目三殺進(jìn)來了
想要了解Go更多內(nèi)容,歡迎掃描下方??關(guān)注公眾號,掃描 [實戰(zhàn)群]二維碼 ,即可進(jìn)群和我們交流~
- 掃碼即可加入實戰(zhàn)群 -
分享、在看與點贊Go
瀏覽
359
共 16829字,需瀏覽 34分鐘
·
2024-05-23 08:00
大家好,我是江湖十年。
我曾經(jīng)寫過一篇文章《測試代碼終極解決方案 Monkey Patching》,里面介紹了 Go 語言中的猴子補(bǔ)丁方案。如今,時隔數(shù)月我又發(fā)現(xiàn)了一款新的工具可以實現(xiàn) Monkey Patching,本文將帶大家一起嘗鮮下這款新的測試工具表現(xiàn)如何。
簡介
簡單一句話介紹 xgo:它是一款強(qiáng)大的的 Go 測試工具集,功能包括 Trap、Mock、Trace、增量覆蓋率。
當(dāng)然,開發(fā)中最常用的還是 Mock 功能,也是本文講解的重點(不要慌,其他功能也會介紹)。
以下是 xgo 支持的所有平臺:
| x86 | x86_64 (amd64) | arm64 | any other Arch... | |
|---|---|---|---|---|
| Linux | Y | Y | Y | Y |
| Windows | Y | Y | Y | Y |
| macOS | Y | Y | Y | Y |
| any other OS... | Y | Y | Y | Y |
可以發(fā)現(xiàn),xgo 支持所有 go 語言支持的 OS 和 Arch,即它是跨平臺的。
跨平臺這一點是最吸引我的地方,也是能讓 xgo 脫穎而出的關(guān)鍵。
此外,xgo 還是并發(fā)安全的 Monkey Patching 方案,這點也是有別于其他方案的一個亮點。
本文就以測試一個 HTTP 服務(wù)程序來演示 xgo 的基本使用。
HTTP 服務(wù)程序示例
假設(shè)我們有一個 HTTP 服務(wù)程序?qū)ν馓峁┯脩舴?wù),代碼如下:
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"github.com/julienschmidt/httprouter"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
type User struct {
ID int
Name string
}
func NewMySQLDB(host, port, user, pass, dbname string) (*gorm.DB, error) {
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local",
user, pass, host, port, dbname)
return gorm.Open(mysql.Open(dsn), &gorm.Config{})
}
func NewUserHandler(store *gorm.DB) *UserHandler {
return &UserHandler{store: store}
}
type UserHandler struct {
store *gorm.DB
}
func (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
w.Header().Set("Content-Type", "application/json")
body, err := io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = fmt.Fprintf(w, `{"msg":"%s"}`, err.Error())
return
}
defer func() { _ = r.Body.Close() }()
u := User{}
if err := json.Unmarshal(body, &u); err != nil {
w.WriteHeader(http.StatusBadRequest)
_, _ = fmt.Fprintf(w, `{"msg":"%s"}`, err.Error())
return
}
if err := h.store.Create(&u).Error; err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprintf(w, `{"msg":"%s"}`, err.Error())
return
}
w.WriteHeader(http.StatusCreated)
}
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
id := ps[0].Value
uid, _ := strconv.Atoi(id)
w.Header().Set("Content-Type", "application/json")
var u User
if err := h.store.First(&u, uid).Error; err != nil {
w.WriteHeader(http.StatusInternalServerError)
_, _ = fmt.Fprintf(w, `{"msg":"%s"}`, err.Error())
return
}
_, _ = fmt.Fprintf(w, `{"id":%d,"name":"%s"}`, u.ID, u.Name)
}
func setupRouter(handler *UserHandler) *httprouter.Router {
router := httprouter.New()
router.POST("/users", handler.CreateUser)
router.GET("/users/:id", handler.GetUser)
return router
}
func main() {
mysqlDB, _ := NewMySQLDB("localhost", "3306", "user", "password", "test")
handler := NewUserHandler(mysqlDB)
router := setupRouter(handler)
_ = http.ListenAndServe(":8000", router)
}
這是一個簡單的 Web Server 程序,服務(wù)監(jiān)聽 8000 端口,提供了兩個接口:
POST /users 用來創(chuàng)建用戶。
GET /users/:id 用來查詢指定 ID 對應(yīng)的用戶信息。
代碼邏輯比較簡單,我就不詳細(xì)講解了。
為了保證業(yè)務(wù)的正確性,我們應(yīng)該對 (*UserHandler).CreateUser 和 (*UserHandler).GetUser 這兩個 Handler 方法進(jìn)行單元測試。
使用 xgo 進(jìn)行單元測試
安裝
xgo 使用前必須通過 go install 命令進(jìn)行安裝:
$ go install github.com/xhd2015/xgo/cmd/xgo@latest
$ xgo version
1.0.35
編寫測試代碼
以 (*UserHandler).CreateUser 方法為例演習(xí)下如何編寫測試代碼。
我們先來分析下這個方法的依賴項:
首先UserHandler 這個結(jié)構(gòu)體本身有一個 store 屬性,依賴了 *gorm.DB 對象。
其次,CreateUser 方法還接收三個參數(shù),它們都屬于 HTTP 網(wǎng)絡(luò)相關(guān)的外部依賴,你可以在我的另一篇文章《在 Go 語言單元測試中如何解決 HTTP 網(wǎng)絡(luò)依賴問題》中找到解決方案,就不在本文中進(jìn)行講解了。
所以,我們應(yīng)該要想辦法解決 *gorm.DB 這個外部依賴。
由于我們編寫代碼時,沒有為支持單元測試而專門使用接口來進(jìn)行解耦,導(dǎo)致 UserHandler 結(jié)構(gòu)體直接依賴了 *gorm.DB 結(jié)構(gòu)體對象,無法使用 gomock 工具對依賴項進(jìn)行 Mock。
在不改變代碼的前提下,我們可以使用 xgo 提供的 Monkey Patching 技術(shù)為依賴對象 *gorm.DB 打上猴子補(bǔ)丁,以此來解決測試代碼中難以調(diào)用 h.store.First(&u, uid).Error 方法問題。
要使用 xgo 編寫測試,需要引入 xgo 提供的 runtime 包,所以先使用 go get 命令將其添加到 go.mod 依賴項:
go get github.com/xhd2015/xgo/runtime@latest
使用 xgo 為 (*UserHandler).CreateUser 方法編寫的測試代碼如下:
package main
import (
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/xhd2015/xgo/runtime/mock"
"gorm.io/gorm"
)
func TestUserHandler_CreateUser(t *testing.T) {
mysqlDB := &gorm.DB{}
handler := NewUserHandler(mysqlDB)
router := setupRouter(handler)
// 為 mysqlDB 打上猴子補(bǔ)丁,替換其 Create 方法
mock.Patch(mysqlDB.Create, func(value interface{}) (tx *gorm.DB) {
expected := &User{
Name: "user1",
}
actual := value.(*User)
assert.Equal(t, expected, actual)
return mysqlDB
})
w := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/users", strings.NewReader(`{"name": "user1"}`))
router.ServeHTTP(w, req)
// 斷言成功響應(yīng)
assert.Equal(t, 201, w.Code)
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
assert.Equal(t, "", w.Body.String())
}
我們使用 xgo 提供的 mock.Patch 方法,為 mysqlDB 對象的 Create 方法打了一個猴子補(bǔ)丁,然后使用匿名函數(shù)來實現(xiàn)這個 Create 方法,并且,在匿名函數(shù)的內(nèi)部還對 Create 方法接收到的參數(shù)進(jìn)行了驗證。
沒錯,xgo 使用起來就是這么簡單,這也體現(xiàn)了猴子補(bǔ)丁的強(qiáng)大,它能原地修改 mysqlDB.Create 方法的實現(xiàn)。
這樣,在執(zhí)行測試代碼時,測試方法將不再執(zhí)行 mysqlDB.Create 原方法內(nèi)部邏輯,而會被替換為調(diào)用在此定義的匿名函數(shù)邏輯。
要執(zhí)行測試,我們不能像原來一樣使用 go test 來執(zhí)行測試函數(shù),需要將 go 命令替換為 xgo 命令:
$ xgo test -v -run TestUserHandler_CreateUser
=== RUN TestUserHandler_CreateUser
--- PASS: TestUserHandler_CreateUser (0.00s)
PASS
ok github.com/jianghushinian/blog-go-example/test/xgo 0.524s
測試通過。
xgo 用法跟普通的 go test 用法完全相同,這也大大簡化了我們切換命令的心智負(fù)擔(dān),幾乎零成本切換。
NOTE: 如果直接使用
go test -v -run TestUserHandler_CreateUser執(zhí)行測試將得到報錯,讀者可自行測試。
接下來我們再為 (*UserHandler).GetUser 方法編寫如下測試代碼:
func TestUserHandler_GetUser(t *testing.T) {
mysqlDB := &gorm.DB{}
handler := NewUserHandler(mysqlDB)
router := setupRouter(handler)
// 為 mysqlDB 打上猴子補(bǔ)丁,替換其 First 方法
mock.Patch(mysqlDB.First, func(dest interface{}, conds ...interface{}) (tx *gorm.DB) {
assert.Equal(t, dest, &User{})
assert.Equal(t, len(conds), 1)
assert.Equal(t, conds[0], 1)
u := dest.(*User)
u.ID = 1
u.Name = "user1"
return mysqlDB
})
w := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/users/1", nil)
router.ServeHTTP(w, req)
assert.Equal(t, 200, w.Code)
assert.Equal(t, "application/json", w.Header().Get("Content-Type"))
assert.Equal(t, `{"id":1,"name":"user1"}`, w.Body.String())
}
與之前的套路如出一轍,使用 xgo 執(zhí)行測試:
$ xgo test -v -run TestUserHandler_GetUser
=== RUN TestUserHandler_GetUser
--- PASS: TestUserHandler_GetUser (0.00s)
PASS
ok github.com/jianghushinian/blog-go-example/test/xgo 0.424s
測試通過。
現(xiàn)在,你也許會問,這種使用 mock.Patch 打過猴子補(bǔ)丁的測試代碼需要使用 xgo 才能執(zhí)行,那沒有用到 mock.Patch 的普通測試代碼能不能也用 xgo 執(zhí)行呢?答案是肯定的。
比如我們隨意寫一個沒什么意義的 demo 測試:
func TestDemo(t *testing.T) {
t.Log("---------- TestDemo ----------")
}
使用 xgo 執(zhí)行測試代碼:
$ xgo test -v -run TestDemo
=== RUN TestDemo
main_test.go:65: ---------- TestDemo ----------
--- PASS: TestDemo (0.00s)
PASS
ok github.com/jianghushinian/blog-go-example/test/xgo 0.219s
測試通過。
使用 xgo 一次執(zhí)行全部測試代碼:
$ xgo test -v
=== RUN TestUserHandler_CreateUser
--- PASS: TestUserHandler_CreateUser (0.00s)
=== RUN TestUserHandler_GetUser
--- PASS: TestUserHandler_GetUser (0.00s)
=== RUN TestDemo
main_test.go:65: ---------- TestDemo ----------
--- PASS: TestDemo (0.00s)
PASS
ok github.com/jianghushinian/blog-go-example/test/xgo 0.175s
測試通過。
這樣我們就統(tǒng)一了執(zhí)行測試代碼的方式,所有測試都可以使用 xgo 來執(zhí)行。理論上,我們只需要將現(xiàn)有項目執(zhí)行 go test 的地方,替換成 xgo test 即可兼容所有測試代碼,這大大降低了引入 xgo 的遷移成本。
xgo 其他功能
前文提到,xgo 核心功能包括 Trap、Mock、Trace、增量覆蓋率。
其實我們上面介紹的 mock.Patch 即為 Mock 功能,不過除了這個 API,xgo 還提供了另外一個 Mock API mock.Mock,實際上這兩個方法底層調(diào)用的是同一個函數(shù),用法也類似,我就不進(jìn)行演示了,感興趣的讀者可以深入源碼進(jìn)行研究。
接下來我將依次介紹下 Trap、Trace、增量覆蓋率這幾個功能。
Trap
Trap 是 xgo 的核心,也是 Mock、Trace 功能的基礎(chǔ),它可以對 Go 函數(shù)進(jìn)行攔截。
以下是一個官方文檔中使用 Trap 的例子:
package main
import (
"context"
"fmt"
"github.com/xhd2015/xgo/runtime/core"
"github.com/xhd2015/xgo/runtime/trap"
)
func init() {
trap.AddInterceptor(&trap.Interceptor{
Pre: func(ctx context.Context, f *core.FuncInfo, args core.Object, results core.Object) (interface{}, error) {
if f.Name == "A" {
fmt.Printf("trap A\n")
return nil, nil
}
if f.Name == "B" {
fmt.Printf("abort B\n")
return nil, trap.ErrAbort
}
return nil, nil
},
})
}
func main() {
A()
B()
}
func A() {
fmt.Printf("A\n")
}
func B() {
fmt.Printf("B\n")
}
使用 go 命令執(zhí)行代碼:
$ go run main.go
A
B
代碼正常執(zhí)行。
如果改為使用 xgo 執(zhí)行代碼;
xgo run main.go
trap A
A
abort B
可以發(fā)現(xiàn),xgo 改變了代碼執(zhí)行結(jié)果,這就是 Trap 的強(qiáng)大之處,xgo 攔截了原有代碼的邏輯,進(jìn)而執(zhí)行攔截器內(nèi)部的邏輯。
不過這種用法并不太多,我們更多的場景還是使用更上層的 Mock 功能來編寫測試代碼。
Trace
Trace 功能可以將 Go 程序執(zhí)行過程可視化,在一定程度上可以替代 Debug 工具,方便我們以可視化的方式進(jìn)行代碼調(diào)試。
要想使用 Trace 功能,也很簡單,僅需要在使用 xgo 執(zhí)行測試代碼時加上 --strace 標(biāo)志:
$ xgo test -v -run TestDemo --strace
=== RUN TestDemo
main_test.go:65: ---------- TestDemo ----------
--- PASS: TestDemo (0.00s)
PASS
ok github.com/jianghushinian/blog-go-example/test/xgo 0.162s
執(zhí)行以上命令會在當(dāng)前目錄生成一個 TestDemo.json 文件,文件中即為可視化所需報告數(shù)據(jù)。
接下來執(zhí)行如下命令即可開啟 Trace 可視化服務(wù):
$ xgo tool trace TestDemo.json
Server listen at http://localhost:7070
此時會自動打開瀏覽器顯示類似如下頁面:
左側(cè)列表可視化的展示了堆棧跟蹤信息,每項前面如果是藍(lán)色表示被調(diào)用函數(shù)正常返回,紅色表示返回錯誤。如果你使用 VSCode 開發(fā)代碼的話,點擊 VSCode 圖標(biāo)還會自動定位到 VSCode 中函數(shù)定義的位置,方便排查問題。
遺憾的是,經(jīng)過筆者實測目前此功能還不夠穩(wěn)定,存在影響使用的 BUG,甚至經(jīng)常超時無法生成 Trace 文件。
增量覆蓋率
我們要介紹的最后一個功能是增量覆蓋率。
go test 本身支持測試覆蓋率,不過 xgo 更近一步,它可以根據(jù) Git 變更,計算出增量測試覆蓋率,極大方便了代碼 review 的過程。
為了查看變更代碼的增量覆蓋率,我們對 GetUser 方法代碼進(jìn)行了如下修改:
使用 xgo 命令輸出測試覆蓋率文件:
$ xgo test -v -coverpkg . -coverprofile cover.out
=== RUN TestUserHandler_CreateUser
--- PASS: TestUserHandler_CreateUser (0.00s)
=== RUN TestUserHandler_GetUser
true...
--- PASS: TestUserHandler_GetUser (0.00s)
=== RUN TestDemo
main_test.go:65: ---------- TestDemo ----------
--- PASS: TestDemo (0.00s)
PASS
coverage: 54.8% of statements in .
ok github.com/jianghushinian/blog-go-example/test/xgo 0.962s
NOTE: 由于此功能基于 Git,所以如果代碼不在 Git 倉庫,則執(zhí)行命令會報錯。并且筆者實測,如果一個 Git 倉庫存在多個項目情況下,執(zhí)行命令也會報錯。
得到測試覆蓋率文件 cover.out 后,執(zhí)行以下命令啟動一個本地 Server 來展示測試覆蓋率:
$ xgo tool coverage serve cover.out
執(zhí)行命令后,xgo 會自動開啟瀏覽器并訪問 http://localhost:8000 地址:
默認(rèn)展示的就是增量代碼測試覆蓋率。藍(lán)色表示已覆蓋,黃色表示未覆蓋,展示結(jié)果符合預(yù)期。
我們也可以切換成查看全局代碼測試覆蓋率:
以上就是 xgo 對增量測試覆蓋率的支持,還是能夠比較方便查看增量代碼測試覆蓋率的。
總結(jié)
xgo 作為一款 Monkey Patching 解決方案的工具,其支持 Trap、Mock、Trace、增量覆蓋率幾個功能,方便我們用來編寫單元測試。
Trap 是 xgo 的核心,雖然不太常用,但上層的 Mock 和 Trace 都是基于 Trap 實現(xiàn)的。
Mock 是我們用的最多的功能,其可以實現(xiàn)跨平臺的 Monkey Patching 解決方案。
Trace 功能可以方便我們以可視化的形式對代碼進(jìn)行 Debug。
而增量覆蓋率則可以方便我們在 review 代碼時可視化的感知到增量代碼的測試情況。
總結(jié)下 xgo 目前的優(yōu)點和不足:
優(yōu)點:
-
相比于其他 Monkey Patching 解決方案, xgo的跨平臺支持最好,這也是我認(rèn)為xgo最大的優(yōu)勢。 -
并發(fā)安全,多個協(xié)程下 Mock 完全隔離。 -
相比于 gomonkey等,xgo在Patch后不需要進(jìn)行Reset操作對猴子補(bǔ)丁進(jìn)行恢復(fù)。 -
使用方便,僅需要將 go命令替換成xgo即可。
不足:
-
雖然使用時僅需將 go命令替換成xgo即可,但這也意味著需要單獨安裝xgo才行,原來的go test命令執(zhí)行會報錯,不夠友好。 -
不能隨意升級 Go 版本,至少目前筆者測試結(jié)果是這樣,我在使用 go1.22.0時有些命令會報錯,換成go1.21.0則沒有問題。 -
Trace 功能速度比較慢,且存在 BUG。 -
沒有提供 --help命令,較為遺憾。
以上優(yōu)點和不足,都是我個人基于當(dāng)前版本測試下來的主觀使用體驗,希望 xgo 能夠盡快發(fā)展起來,補(bǔ)齊短板。
如果,你想了解 xgo 的誕生以及實現(xiàn)方案,前幾天 xgo 作者在 Go 夜讀分享了Go 夜讀第 151 期:xgo: 基于編譯期代碼重寫實現(xiàn) Mock 和 Trace: https://talkgo.org/t/topic/5514。
本文完整代碼示例我放在了 GitHub 上,歡迎點擊查看。
希望此文能對你有所幫助。
P.S.
本來預(yù)計這段時間不會再寫 Go 測試相關(guān)文章了,因為之前寫的測試相關(guān)文章已經(jīng)覆蓋了大部分日常編寫單元測試的場景。不過前段時間 xgo 作者聯(lián)系到我,跟我分享了 xgo 項目,解決了 gomonkey 項目的兼容性和并發(fā)問題。
因為深入研究 Go 語言 Monkey Patching 解決方案這個方向的人很少,所以我對這個項目還是比較感興趣的,于是花時間體驗了下,便有了此文。
xgo 項目給在 Go 語言中單元測試帶來了新的可能性,是目前我體驗過的最方便也是兼容性最好的 Monkey Patching 方案。
誠然,xgo 項目還不夠成熟,它還非常年輕,剛開源出來不久,但是它開了個好頭,期待給它足夠的時間,能夠成長為 Go 社區(qū)里 Monkey Patch 解決方案中最為流行的項目之一。
參考
-
xgo 源碼:https://github.com/xhd2015/xgo -
xgo: 在go中使用-toolexec實現(xiàn)猴子補(bǔ)丁:https://blog.xhd2015.xyz/zh/posts/xgo-monkey-patching-in-go-using-toolexec/ -
xgo trace: 一個強(qiáng)大的Go堆??梢暬ぞ撸篽ttps://blog.xhd2015.xyz/zh/posts/xgo-trace_a-powerful-visualization-tool-in-go/ -
Go 夜讀第 151 期:xgo: 基于編譯期代碼重寫實現(xiàn) Mock 和 Trace:https://talkgo.org/t/topic/5514 -
本文 GitHub 示例代碼:https://github.com/jianghushinian/blog-go-example/tree/main/test/xgo
推薦閱讀:
Go 1.22 的新增功能系列之三:slices.Concat
6 個必須嘗試的將代碼轉(zhuǎn)換為引人注目的圖表的工具
Go 1.22 的新增功能系列之二:reflect.TypeFor
Go早期是如何在Google內(nèi)部發(fā)展起來的
Go區(qū)不大,創(chuàng)造神話,科目三殺進(jìn)來了
想要了解Go更多內(nèi)容,歡迎掃描下方??關(guān)注公眾號,掃描 [實戰(zhàn)群]二維碼 ,即可進(jìn)群和我們交流~
- 掃碼即可加入實戰(zhàn)群 -
