前面几篇文章,我讲解了在 Go 语言中如何编写测试代码,因为有时候我们编写的代码难以测试,我又写了一篇文章专门讲解在 Go 语言中如何编写出可测试的代码。

但有些时候,我们可能需要维护早期编写的“烂代码”,这些代码不方便测试,可维护阶段需要修改代码,为了验证代码功能正常,我们又不得不补充测试。针对这种情况,本文将向大家介绍一种测试代码的终极解决方案 —— Monkey Patching。

简介

Monkey Patching 翻译过来叫猴子补丁,如果你写过 Python、JavaScript 等动态语言代码,想必对猴子补丁不会太陌生。如果你对猴子补丁不太了解,可以看下我的另一篇文章《Python 中的猴子补丁》

如果你对在 Go 这种静态编程语言中,如何实现 Monkey Patching 比较感兴趣,可以看下这篇文章

HTTP 服务程序示例

假设我们有一个 HTTP 服务程序对外提供服务,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
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 进行单元测试。

使用 Monkey Patching 编写测试

这里以 (*UserHandler).CreateUser 为例进行讲解如何使用 Monkey Patching 编写测试。

先来分析下这个方法的依赖项:

首先 UserHandler 这个结构体本身有一个 store 属性,依赖了 *gorm.DB 对象。

其次,CreateUser 方法还接收三个参数,它们都属于 HTTP 网络相关的外部依赖,你可以在我的另一篇文章《在 Go 语言单元测试中如何解决 HTTP 网络依赖问题》中找到解决方案,就不在本文中进行讲解了。

所以,我们应该要想办法解决 *gorm.DB 这个外部依赖。

由于我们编写代码时,没有考虑如何编写测试,所以就没有使用接口来进行解耦,导致 UserHandler 结构体直接依赖了 *gorm.DB 结构体对象。

在不改变代码的前提下,我们可以使用 Monkey Patching 技术为依赖对象 *gorm.DB 打上猴子补丁,以此来解决测试代码中难以调用 h.store.First(&u, uid).Error 方法问题。

我们可以使用 gomonkey 来实现 Monkey Patching,使用如下命令安装:

1
$ go get github.com/agiledragon/gomonkey/v2

使用 gomonkey(*UserHandler).CreateUser 方法编写的测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func TestUserHandler_CreateUser(t *testing.T) {
mysqlDB := &gorm.DB{}
handler := NewUserHandler(mysqlDB)
router := setupRouter(handler)

// 为 mysqlDB 打上猴子补丁,替换其 Create 方法
patches := gomonkey.ApplyMethod(reflect.TypeOf(mysqlDB), "Create",
func(in *gorm.DB, value interface{}) (tx *gorm.DB) {
expected := &User{
Name: "user1",
}
actual := value.(*User)
assert.Equal(t, expected, actual)
return in
})
// 测试执行完成后将猴子补丁复原
defer patches.Reset()

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())
}

首先我们直接使用 &gorm.DB{} 创建了一个 *gorm.DB 对象,注意这里并没有通过 NewMySQLDB 方法来打开一个真正的数据库连接,这仅仅是一个空对象。

然后将其传递给 NewUserHandler 来完成构造 *UserHandler 对象的正常流程。

接下来,我们要重点关注的是如下这部分代码:

1
2
3
4
5
6
7
8
9
10
11
12
// 为 mysqlDB 打上猴子补丁,替换其 Create 方法
patches := gomonkey.ApplyMethod(reflect.TypeOf(mysqlDB), "Create",
func(in *gorm.DB, value interface{}) (tx *gorm.DB) {
expected := &User{
Name: "user1",
}
actual := value.(*User)
assert.Equal(t, expected, actual)
return in
})
// 测试执行完成后将猴子补丁复原
defer patches.Reset()

我们使用 gomonkey 库的 ApplyMethod 方法,为 mysqlDB 对象的 Create 方法打了一个猴子补丁,然后使用匿名函数来实现这个 Create 方法,并且,在匿名函数的内部还对 Create 方法接收到的参数进行了验证。

gomonkey.ApplyMethod 方法返回一个 *gomonkey.Patches 对象,使用 defer 语句延迟调用 patches.Reset(),可以在测试执行完成后将被 Monkey Patching 的对象进行还原。

这就是猴子补丁的强大,它能原地修改 mysqlDB.Create 方法的实现。

使用 go test 来执行测试函数:

1
2
3
4
5
GOARCH=amd64 go test -gcflags=all=-l -p 1 -v
=== RUN TestUserHandler_CreateUser
--- PASS: TestUserHandler_CreateUser (0.02s)
PASS
ok github.com/jianghushinian/blog-go-example/test/monkeypatching 0.675s

测试通过。

注意,在执行测试时,我指定了 GOARCH=amd64 环境变量。这是因为我的主机是 Apple M2 芯片的 ARM 平台,如果你是 X86 平台则无需指定此环境变量。

此外,我们还为 go test 命令指定了两个特殊参数:

-gcflags=all=-l 参数是用来关闭 Go 语言内联优化的。默认情况下,Go 在构建代码时会进行内联优化,但是 gomonkey 并不支持这一功能,这与其实现原理有关。

-p 1 参数可以将执行测试的代码并发数置为 1。这是由于 gomonkey 不是并发安全的,这同样与其实现原理有关。

虽然执行测试代码时需要多传递两个参数,但 gomonkey 为我们提供的便利性远大于这点小麻烦。

总结

本文介绍了一种编写测试代码的终极解决方案 Monkey Patching,使用这项技术,可以在不手动修改程序代码的情况下,来完成对某个对象的原地替换。

gomonkey 库非常强大,它不仅能够为结构体的方法打上猴子补丁,它还支持为一个函数、一个全局变量、一个函数变量等打上猴子补丁,更多方法可以参考这篇文章

不过使用 gomonkey 也有很多缺点,它不支持 Go 语言的内联优化,也不支持并发的执行测试代码,并且对于 ARM 平台支持不够完善。

所以,我们应该视情况来考虑是否要使用 gomonkey

本文完整代码示例我放在了 GitHub 上,欢迎点击查看。

希望此文能对你有所帮助。

P.S.

其实,对于是否要写下这篇文章我是很犹豫的,因为我不推荐在 Go 中使用 Monkey Patching 技术,引入 Monkey Patching 就意味着代码里存在“坏味道”。但是,有些时候,我们工作中总要跟“烂代码”做斗争,当重构代码代价大于收益时,我们还是要有一种方案来解决难以编写测试代码的问题,Monkey Patching 就是我们编写测试的终极解决方案。

gomonkey 库是一位国人开发的,其思想起源于 monkey 项目。monkey 库的作者虽然创造了在 Go 语言中实现 Monkey Patching 的技术,但是他却不推荐使用 monkeymonkey 在创建之初就存在争议,可以在 Hacker News上看到当时的讨论。并且,作者最终将 monkey 库的许可证设为了不允许他人使用,可以参考这篇文章,有趣的是,文章结尾作者推荐了 gomonkey 项目。

最后,还是要提醒大家,不到万不得已,不推荐使用猴子补丁解决问题。

联系我

参考