专栏名称: GoCN
最具规模和生命力的 Go 开发者社区
目录
相关文章推荐
央视新闻  ·  当心!这些常喝的饮品,正在悄悄升高你的尿酸 ·  17 小时前  
半岛晨报  ·  突发!刘诗诗将所持股权转让给吴奇隆 ·  3 天前  
51好读  ›  专栏  ›  GoCN

xgo: 一款新鲜出炉的 Go 代码测试利器

GoCN  · 公众号  ·  · 2024-05-23 01:15

正文

大家好,我是江湖十年。

我曾经写过一篇文章 《测试代码终极解决方案 Monkey Patching》 ,里面介绍了 Go 语言中的猴子补丁方案。如今,时隔数月我又发现了一款新的工具可以实现 Monkey Patching,本文将带大家一起尝鲜下这款新的测试工具表现如何。

简介

简单一句话介绍 xgo :它是一款强大的的 Go 测试工具集,功能包括 Trap、Mock、Trace、增量覆盖率。

当然,开发中最常用的还是 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

可以发现, 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 nilnil
   }
   if f.Name == "B" {
    fmt.Printf("abort B\n")
    return nil, trap.ErrAbort
   }
   return nilnil
  },
 })
}

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 目前的优点和不足:

优点:

  • 相比于其他 Monkey Patching 解决方案, xgo 的跨平台支持最好,这也是我认为 xgo 最大的优势。
  • 并发安全,多个协程下 Mock 完全隔离。
  • 相比于 gomonkey 等, xgo Patch 后不需要进行 Reset 操作对猴子补丁进行恢复。






请到「今天看啥」查看全文