写点什么

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

作者:江湖十年
  • 2023-08-28
    浙江
  • 本文字数:4131 字

    阅读完需:约 14 分钟

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


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

简介

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


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

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 进行单元测试。

使用 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,使用如下命令安装:


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


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


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 对象的正常流程。


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


// 为 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 来执行测试函数:


GOARCH=amd64 go test -gcflags=all=-l -p 1 -v=== RUN   TestUserHandler_CreateUser--- PASS: TestUserHandler_CreateUser (0.02s)PASSok      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 项目。


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


联系我



参考


  • Monkey Patching in Go:https://bou.ke/blog/monkey-patching-in-go/

  • gomonkey:https://github.com/agiledragon/gomonkey

  • gomonkey 1.0 正式发布!:https://www.jianshu.com/p/633b55d73ddd

  • 你该刷新 gomonkey 的惯用法了:https://www.jianshu.com/p/25d49af216b7

发布于: 2023-08-28阅读数: 20
用户头像

江湖十年

关注

野生程序员 2018-11-10 加入

分享不限于 Go、Python、Docker、K8s 技术。

评论

发布
暂无评论
测试代码终极解决方案 Monkey Patching_golang_江湖十年_InfoQ写作社区