测试代码终极解决方案 Monkey Patching

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

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

简介

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

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

HTTP 服务程序示例

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

go 复制代码
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,使用如下命令安装:

bash 复制代码
$ go get github.com/agiledragon/gomonkey/v2

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

go 复制代码
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 对象的正常流程。

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

go 复制代码
// 为 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 来执行测试函数:

bash 复制代码
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 项目。

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

联系我

参考

相关推荐
王解23 分钟前
Jest项目实战(4):将工具库顺利迁移到GitHub的完整指南
单元测试·github
假装我不帅37 分钟前
asp.net framework从webform开始创建mvc项目
后端·asp.net·mvc
神仙别闹40 分钟前
基于ASP.NET+SQL Server实现简单小说网站(包括PC版本和移动版本)
后端·asp.net
计算机-秋大田1 小时前
基于Spring Boot的船舶监造系统的设计与实现,LW+源码+讲解
java·论文阅读·spring boot·后端·vue
货拉拉技术1 小时前
货拉拉-实时对账系统(算盘平台)
后端
掘金酱2 小时前
✍【瓜分额外奖金】11月金石计划附加挑战赛-活动命题发布
人工智能·后端
代码之光_19802 小时前
保障性住房管理:SpringBoot技术优势分析
java·spring boot·后端
ajsbxi2 小时前
苍穹外卖学习记录
java·笔记·后端·学习·nginx·spring·servlet
架构师那点事儿3 小时前
golang 用unsafe 无所畏惧,但使用不得到会panic
架构·go·掘金技术征文
颜淡慕潇3 小时前
【K8S问题系列 |1 】Kubernetes 中 NodePort 类型的 Service 无法访问【已解决】
后端·云原生·容器·kubernetes·问题解决