测试代码终极解决方案 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 项目。

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

联系我

参考

相关推荐
虫小宝26 分钟前
解决Spring Boot中的安全漏洞与防护策略
java·spring boot·后端
续亮~2 小时前
6、Redis系统-数据结构-06-跳表
java·数据结构·数据库·redis·后端·缓存
夜色呦2 小时前
深入MOJO编程语言的单元测试世界
单元测试·mojo
五敷有你2 小时前
Go:hello world
开发语言·后端·golang
拔剑纵狂歌2 小时前
Golang异常处理机制
开发语言·后端·golang·go
缘友一世3 小时前
Armbian 1panel面板工具箱中FTP服务无法正常启动的解决方法
linux·运维·后端·1panel
weixin_419349793 小时前
flask使用定时任务flask_apscheduler(APScheduler)
后端·python·flask
乐之者v3 小时前
Spring之 IoC、BeanFactory、ApplicationContext
java·后端·spring
一起学习计算机3 小时前
[附源码]基于Flask的演唱会购票系统
后端·python·flask
字节跳动数据平台3 小时前
深耕分析型数据库领域,火山引擎ByteHouse入围《2024爱分析数据库厂商全景报告
数据库·后端·云原生