xgo: 一款新鮮出爐的 Go 代碼測試利器

大家好,我是江湖十年。

我曾經寫過一篇文章《測試代碼終極解決方案 Monkey Patching》,裏面介紹了 Go 語言中的猴子補丁方案。如今,時隔數月我又發現了一款新的工具可以實現 Monkey Patching,本文將帶大家一起嚐鮮下這款新的測試工具表現如何。

簡介

簡單一句話介紹 xgo:它是一款強大的的 Go 測試工具集,功能包括 Trap、Mock、Trace、增量覆蓋率。

當然,開發中最常用的還是 Mock 功能,也是本文講解的重點(不要慌,其他功能也會介紹)。

以下是 xgo 支持的所有平臺:

ZW4kxO

可以發現,xgo 支持所有 go 語言支持的 OS 和 Arch,即它是跨平臺的。

跨平臺這一點是最吸引我的地方,也是能讓 xgo 脫穎而出的關鍵。

此外,xgo 還是併發安全的 Monkey Patching 方案,這點也是有別於其他方案的一個亮點。

本文就以測試一個 HTTP 服務程序來演示 xgo 的基本使用。

HTTP 服務程序示例

假設我們有一個 HTTP 服務程序對外提供用戶服務,代碼如下:

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 程序,服務監聽 8000 端口,提供了兩個接口:

POST /users 用來創建用戶。

GET /users/:id 用來查詢指定 ID 對應的用戶信息。

代碼邏輯比較簡單,我就不詳細講解了。

爲了保證業務的正確性,我們應該對 (*UserHandler).CreateUser(*UserHandler).GetUser 這兩個 Handler 方法進行單元測試。

使用 xgo 進行單元測試

安裝

xgo 使用前必須通過 go install 命令進行安裝:

$ go install github.com/xhd2015/xgo/cmd/xgo@latest
$ xgo version
1.0.35

編寫測試代碼

(*UserHandler).CreateUser 方法爲例演習下如何編寫測試代碼。

我們先來分析下這個方法的依賴項:

首先UserHandler 這個結構體本身有一個 store 屬性,依賴了 *gorm.DB 對象。

其次,CreateUser 方法還接收三個參數,它們都屬於 HTTP 網絡相關的外部依賴,你可以在我的另一篇文章《在 Go 語言單元測試中如何解決 HTTP 網絡依賴問題》中找到解決方案,就不在本文中進行講解了。

所以,我們應該要想辦法解決 *gorm.DB 這個外部依賴。

由於我們編寫代碼時,沒有爲支持單元測試而專門使用接口來進行解耦,導致 UserHandler 結構體直接依賴了 *gorm.DB 結構體對象,無法使用 gomock 工具對依賴項進行 Mock。

在不改變代碼的前提下,我們可以使用 xgo 提供的 Monkey Patching 技術爲依賴對象 *gorm.DB 打上猴子補丁,以此來解決測試代碼中難以調用 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 打上猴子補丁,替換其 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)

 // 斷言成功響應
 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 方法打了一個猴子補丁,然後使用匿名函數來實現這個 Create 方法,並且,在匿名函數的內部還對 Create 方法接收到的參數進行了驗證。

沒錯,xgo 使用起來就是這麼簡單,這也體現了猴子補丁的強大,它能原地修改 mysqlDB.Create 方法的實現。

這樣,在執行測試代碼時,測試方法將不再執行 mysqlDB.Create 原方法內部邏輯,而會被替換爲調用在此定義的匿名函數邏輯。

要執行測試,我們不能像原來一樣使用 go test 來執行測試函數,需要將 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 用法完全相同,這也大大簡化了我們切換命令的心智負擔,幾乎零成本切換。

NOTE: 如果直接使用 go test -v -run TestUserHandler_CreateUser 執行測試將得到報錯,讀者可自行測試。

接下來我們再爲 (*UserHandler).GetUser 方法編寫如下測試代碼:

func TestUserHandler_GetUser(t *testing.T) {
 mysqlDB := &gorm.DB{}
 handler := NewUserHandler(mysqlDB)
 router := setupRouter(handler)

 // 爲 mysqlDB 打上猴子補丁,替換其 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 執行測試:

$ 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

測試通過。

現在,你也許會問,這種使用 mock.Patch 打過猴子補丁的測試代碼需要使用 xgo 才能執行,那沒有用到 mock.Patch 的普通測試代碼能不能也用 xgo 執行呢?答案是肯定的。

比如我們隨意寫一個沒什麼意義的 demo 測試:

func TestDemo(t *testing.T) {
 t.Log("---------- TestDemo ----------")
}

使用 xgo 執行測試代碼:

$ 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 一次執行全部測試代碼:

$ 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

測試通過。

這樣我們就統一了執行測試代碼的方式,所有測試都可以使用 xgo 來執行。理論上,我們只需要將現有項目執行 go test 的地方,替換成 xgo test 即可兼容所有測試代碼,這大大降低了引入 xgo 的遷移成本。

xgo 其他功能

前文提到,xgo 核心功能包括 Trap、Mock、Trace、增量覆蓋率。

其實我們上面介紹的 mock.Patch 即爲 Mock 功能,不過除了這個 API,xgo 還提供了另外一個 Mock API mock.Mock,實際上這兩個方法底層調用的是同一個函數,用法也類似,我就不進行演示了,感興趣的讀者可以深入源碼進行研究。

接下來我將依次介紹下 Trap、Trace、增量覆蓋率這幾個功能。

Trap

Trap 是 xgo 的核心,也是 Mock、Trace 功能的基礎,它可以對 Go 函數進行攔截。

以下是一個官方文檔中使用 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 命令執行代碼:

$ go run main.go
A
B

代碼正常執行。

如果改爲使用 xgo 執行代碼;

xgo run main.go
trap A
A
abort B

可以發現,xgo 改變了代碼執行結果,這就是 Trap 的強大之處,xgo 攔截了原有代碼的邏輯,進而執行攔截器內部的邏輯。

不過這種用法並不太多,我們更多的場景還是使用更上層的 Mock 功能來編寫測試代碼。

Trace

Trace 功能可以將 Go 程序執行過程可視化,在一定程度上可以替代 Debug 工具,方便我們以可視化的方式進行代碼調試。

要想使用 Trace 功能,也很簡單,僅需要在使用 xgo 執行測試代碼時加上 --strace 標誌:

$ 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

執行以上命令會在當前目錄生成一個 TestDemo.json 文件,文件中即爲可視化所需報告數據。

接下來執行如下命令即可開啓 Trace 可視化服務:

$ xgo tool trace TestDemo.json                     
Server listen at http://localhost:7070

此時會自動打開瀏覽器顯示類似如下頁面:

Trace Demo

左側列表可視化的展示了堆棧跟蹤信息,每項前面如果是藍色表示被調用函數正常返回,紅色表示返回錯誤。如果你使用 VSCode 開發代碼的話,點擊 VSCode 圖標還會自動定位到 VSCode 中函數定義的位置,方便排查問題。

遺憾的是,經過筆者實測目前此功能還不夠穩定,存在影響使用的 BUG,甚至經常超時無法生成 Trace 文件。

增量覆蓋率

我們要介紹的最後一個功能是增量覆蓋率。

go test 本身支持測試覆蓋率,不過 xgo 更近一步,它可以根據 Git 變更,計算出增量測試覆蓋率,極大方便了代碼 review 的過程。

爲了查看變更代碼的增量覆蓋率,我們對 GetUser 方法代碼進行了如下修改:

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 倉庫,則執行命令會報錯。並且筆者實測,如果一個 Git 倉庫存在多個項目情況下,執行命令也會報錯。

得到測試覆蓋率文件 cover.out 後,執行以下命令啓動一個本地 Server 來展示測試覆蓋率:

$ xgo tool coverage serve cover.out

執行命令後,xgo 會自動開啓瀏覽器並訪問 http://localhost:8000 地址:

Incremental Coverage

默認展示的就是增量代碼測試覆蓋率。藍色表示已覆蓋,黃色表示未覆蓋,展示結果符合預期。

我們也可以切換成查看全局代碼測試覆蓋率:

Full Coverage

以上就是 xgo 對增量測試覆蓋率的支持,還是能夠比較方便查看增量代碼測試覆蓋率的。

總結

xgo 作爲一款 Monkey Patching 解決方案的工具,其支持 Trap、Mock、Trace、增量覆蓋率幾個功能,方便我們用來編寫單元測試。

Trap 是 xgo 的核心,雖然不太常用,但上層的 Mock 和 Trace 都是基於 Trap 實現的。

Mock 是我們用的最多的功能,其可以實現跨平臺的 Monkey Patching 解決方案。

Trace 功能可以方便我們以可視化的形式對代碼進行 Debug。

而增量覆蓋率則可以方便我們在 review 代碼時可視化的感知到增量代碼的測試情況。

總結下 xgo 目前的優點和不足:

優點:

不足:

以上優點和不足,都是我個人基於當前版本測試下來的主觀使用體驗,希望 xgo 能夠儘快發展起來,補齊短板。

如果,你想了解 xgo 的誕生以及實現方案,前幾天 xgo 作者在 Go 夜讀分享了 Go 夜讀第 151 期:xgo: 基於編譯期代碼重寫實現 Mock 和 Trace: https://talkgo.org/t/topic/5514。

本文完整代碼示例我放在了 GitHub 上,歡迎點擊查看。

希望此文能對你有所幫助。

P.S.

本來預計這段時間不會再寫 Go 測試相關文章了,因爲之前寫的測試相關文章已經覆蓋了大部分日常編寫單元測試的場景。不過前段時間 xgo 作者聯繫到我,跟我分享了 xgo 項目,解決了 gomonkey 項目的兼容性和併發問題。

因爲深入研究 Go 語言 Monkey Patching 解決方案這個方向的人很少,所以我對這個項目還是比較感興趣的,於是花時間體驗了下,便有了此文。

xgo 項目給在 Go 語言中單元測試帶來了新的可能性,是目前我體驗過的最方便也是兼容性最好的 Monkey Patching 方案。

誠然,xgo 項目還不夠成熟,它還非常年輕,剛開源出來不久,但是它開了個好頭,期待給它足夠的時間,能夠成長爲 Go 社區裏 Monkey Patch 解決方案中最爲流行的項目之一。

參考

聯繫我

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ry8wcH0Ta6NPsmFyQnbGaA