Go 每日一库:gomonkey 是什么?

Go语言中文网 2022-06-24 08:52

小土跟作者晓龙老师也认识许久,今天转载老师一篇关于gomonkey用法的文章。也容许我在这里给大家介绍一下晓龙老师。如对文章有看法也欢迎大家在留言区进行交流与讨论。

晓龙老师来自中兴通讯,敏捷技术教练,2020年和2021年公司十佳教练,Go语言知名打桩框架gomonkey作者,具有十多年软件架构和开发经验。近年来专注于PaaS和5G等大型平台软件的设计与开发,尤其对于TDD、 DDD 和微服务具有深刻的理解,对于大型软件的重构具有丰富的实战经验。曾经作为演讲嘉宾多次参加全球架构师峰会、全球C++及系统软件技术大会、领域驱动设计中国峰会和TiD质量竞争力大会等,广受好评。

目录

  • 引言

  • gomonkey 惯用法刷新

    • interface 惯用法刷新

    • method 惯用法刷新

    • func 惯用法刷新

    • func var 惯用法刷新

    • constructor 惯用法刷新

  • 小结

引言

gomonkey[1] 是笔者开源的一款 Go 语言 的打桩框架,目标是让用户在单元测试中低成本的完成打桩,从而将精力聚焦于业务功能的开发。gomonkey 接口友好,功能强大,目前已被很多项目使用,用户遍及世界多个国家。

近一年,在诸多用户的共同努力下,gomonkey 社区发展的很快,连续发布了 8 个版本,不仅优化了一些基础特性,而且还新增了很多扩展特性,非常实用接地气。与此同时,gomonkey 的 star 数从 0.5k 跃升到了 1.1k,受到了国内外 gopher 的广泛赞赏和肯定。

gomonkey.png

gomonkey 新增或优化的主要特性汇总:

特性分类贡献者备注
全面支持 arm64 架构新增hengwu0PR55 PR58
全面支持为 private method 打桩了新增hengwu0 lockdown56PR65 PR67 PR85
全面支持 386 架构新增segdumpingPR75
支持为 method 打桩时不传入receiver优化AVOliliPR78
支持为 func/func var/method 打桩时直接指定返回值新增AVOliliPR78
支持为 method 打桩时不必转化为reflect.Type类型,同时兼容原有的用法优化AVOliliPR83
支持为 method 打桩不传入receiver时函数可为变参优化punchioPR90

感谢所有 gomonkey 的贡献者,每一个特性都凝结着大家的心血和汗水。虽然我们不曾见过,但彼此心往一处想,劲往一处使,共同推动 gomonkey 社区持续发展,不断繁荣,从一个胜利走向另一个胜利。

在众多新特性中,gomonkey 全面支持 arm64 架构 是对业界影响最大的一个特性。去年笔者刚发布支持该特性的版本后,就很意外的收到了 Bouk 大神的来信:

letter.png

这里需要强调一下:Bouke 是 Go 语言 monkey[2]工程的创建者,在 2015 年就发表了 Go 语言猴子补丁原理[3]的文章。毫无疑问,gomonkey) 的思维底座主要来自 Bouke 的贡献,向他致敬,非常感谢!

如果你对 gomonkey 全面支持 arm64 架构感兴趣,可以进一步阅读笔者之前写的一篇文章《gomonkey 全面支持 arm64 了》[4]

gomonkey 惯用法刷新

gomonkey 基础特性列表如下:

  • 支持为一个函数打一个桩
  • 支持为一个成员方法打一个桩
  • 支持为一个全局变量打一个桩
  • 支持为一个函数变量打一个桩
  • 支持为一个函数打一个特定的桩序列
  • 支持为一个成员方法打一个特定的桩序列
  • 支持为一个函数变量打一个特定的桩序列

想要了解 gomonkey 的这些基础特性,可以参考几年前笔者的一篇文章《gomonkey 1.0 正式发布》[5]

interface 惯用法刷新

之前很多 gopher 习惯使用 GoMock 框架对 interface 进行打桩,笔者当时也写了一篇文章《GoMock框架使用指南》[6]。后来有一些 gomonkey 用户想用 gomonkey 对 interface 进行打桩,从而减少多个打桩框架的学习成本和测试用例的维护成本。

刷新1:当为 interface 打一个桩时,用户直接复用组合之前的 ApplyFunc 和 ApplyMethod 接口即可

对 interface 打一个桩,其实不用提供类似 ApplyInterface 的接口,而仅仅是让用户复用组合之前的 ApplyFunc 和 ApplyMethod 接口。原因其实很简单,当我们定义了一个 interface 时,系统中就会有一个或多个实现类(struct),我们可以通过 ApplyFunc 接口让 interface 变量指向一个实现类对象,然后通过 ApplyMethod 接口来改变该实现类的行为,这就相当于对 interface 完成了打桩。

示例代码:先构造一个 Etcd 对象 e,通过第一层 convey 调用 ApplyFunc 让 Db 的 interface 变量指向 e,然后在第二层 convey 中调用 ApplyMethod 对 Db 完成打一个桩。

func TestApplyInterfaceReused(t *testing.T) {
    e := &fake.Etcd{}

    Convey("TestApplyInterface", t, func() {
        patches := ApplyFunc(fake.NewDb, func(_ string) fake.Db {
            return e
        })
        defer patches.Reset()
        db := fake.NewDb("mysql")

        Convey("TestApplyInterface"func() {
            info := "hello interface"
            patches.ApplyMethod(e, "Retrieve",
                func(_ *fake.Etcd, _ string) (string, error) {
                    return info, nil
                })
            output, err := db.Retrieve("")
            So(err, ShouldEqual, nil)
            So(output, ShouldEqual, info)
        })
    })
}

刷新2:当为 interface 打一个桩序列时,用户直接复用组合之前的 ApplyFunc 和 ApplyMethodSeq 接口即可

同理,为 interface 打一个桩序列,也不用提供提供类似 ApplyInterfaceSeq 的接口。

示例代码:先构造一个 Etcd 对象 e,通过第一层 convey 调用 ApplyFunc 让 Db 的 interface 变量指向 e,然后在第二层 convey 中调用 ApplyMethodSeq 对 interface Db 完成打一个桩,在第一个第二层 convey 中调用 ApplyMethodSeq  对 Db 完成打一个特定的桩序列。

func TestApplyInterfaceReused(t *testing.T) {
    e := &fake.Etcd{}

    Convey("TestApplyInterface", t, func() {
        patches := ApplyFunc(fake.NewDb, func(_ string) fake.Db {
            return e
        })
        defer patches.Reset()
        db := fake.NewDb("mysql")
        Convey("TestApplyInterfaceSeq"func() {
            info1 := "hello cpp"
            info2 := "hello golang"
            info3 := "hello gomonkey"
            outputs := []OutputCell{
                {Values: Params{info1, nil}},
                {Values: Params{info2, nil}},
                {Values: Params{info3, nil}},
            }
            patches.ApplyMethodSeq(e, "Retrieve", outputs)
            output, err := db.Retrieve("")
            So(err, ShouldEqual, nil)
            So(output, ShouldEqual, info1)
            output, err = db.Retrieve("")
            So(err, ShouldEqual, nil)
            So(output, ShouldEqual, info2)
            output, err = db.Retrieve("")
            So(err, ShouldEqual, nil)
            So(output, ShouldEqual, info3)
        })
    })
}

method 惯用法刷新

先回顾一下 method 打桩的原有方式。

示例如下:reflect.TypeOf 的参数是一个指针类型,而 NewSlice 返回的仅仅是一个 Slice 引用类型,所以仍需再定义一个变量 s。

func TestApplyMethod(t *testing.T) {
    slice := fake.NewSlice()
    var s *fake.Slice
    Convey("TestApplyMethod", t, func() {

        Convey("for succ"func() {
            err := slice.Add(1)
            So(err, ShouldEqual, nil)
            patches := ApplyMethod(reflect.TypeOf(s), "Add"func(_ *fake.Slice, _ int) error {
                return nil
            })
            defer patches.Reset()
            err = slice.Add(1)
            So(err, ShouldEqual, nil)
            err = slice.Remove(1)
            So(err, ShouldEqual, nil)
            So(len(slice), ShouldEqual, 0)
        })
  })
}

刷新3:当为 method 打桩时可以不传入 reflect.TypeOf 类型参数了

示例代码:ApplyMethod 第一个参数以前传 reflect.TypeOf(s),现在仅需传 s,同时兼容原有的用例,就是说新用例可以使用 s 代替  reflect.TypeOf(s),而老用例可以保持 reflect.TypeOf(s) 不变。

func TestApplyMethod(t *testing.T) {
    slice := fake.NewSlice()
    var s *fake.Slice
    Convey("TestApplyMethod", t, func() {

        Convey("for succ"func() {
            err := slice.Add(1)
            So(err, ShouldEqual, nil)
            patches := ApplyMethod(reflect.TypeOf(s), "Add"func(_ *fake.Slice, _ int) error {
                return nil
            })
            defer patches.Reset()
            err = slice.Add(1)
            So(err, ShouldEqual, nil)
            err = slice.Remove(1)
            So(err, ShouldEqual, nil)
            So(len(slice), ShouldEqual, 0)
        })
   })
}

刷新4:当为 method 打桩时可以不传入 receiver 参数了

要使用该特性,就不能再使用 ApplyMethod 接口了,而是使用 ApplyMethodFunc 接口。

示例代码:比上面 TestApplyMethod 示例代码 ApplyMethod 的第三个函数参数 func(_ *fake.Slice, _ int) error 少了第一个子参数 *fake.Slice,而简化成 func(_ int) error。

func TestApplyMethodFunc(t *testing.T) {
    slice := fake.NewSlice()
    var s *fake.Slice
    Convey("TestApplyMethodFunc", t, func() {
        Convey("for succ"func() {
            err := slice.Add(1)
            So(err, ShouldEqual, nil)
            patches := ApplyMethodFunc(s, "Add"func(_ int) error {
                return nil
            })
            defer patches.Reset()
            err = slice.Add(1)
            So(err, ShouldEqual, nil)
            err = slice.Remove(1)
            So(err, ShouldEqual, nil)
            So(len(slice), ShouldEqual, 0)
        })
    })
}

刷新5:当为 method 打桩时可以直接指定返回值

要使用该特性,就不能再使用 ApplyMethod 接口了,而是使用 ApplyMethodReturn 接口。

示例代码:ApplyMethodReturn 接口从第三个参数开始就是桩的返回值。

func TestApplyMethodReturn(t *testing.T) {
    e := &fake.Etcd{}
    Convey("TestApplyMethodReturn", t, func() {
        Convey("declares the values to be returned"func() {
            info := "hello cpp"
            patches := ApplyMethodReturn(e, "Retrieve", info, nil)
            defer patches.Reset()
            for i := 0; i < 10; i++ {
                output, err := e.Retrieve("")
                So(err, ShouldEqual, nil)
                So(output, ShouldEqual, info)
            }
        })
    })
}

刷新6:当 method 为私有时,也可以完成打桩

在 Go 语言中,通过标志符首字母的大小写来控制可见性。当标志符首字母为大写时,标志符可导出,包外可见,否则仅在包内可见,不可导出。

之前对 method 打桩时,method 必须可导出,否则在反射接口中会查询失败,从而导致打桩失败,抛出异常:

panic("retrieve method by name failed")

后来很多 gomonkey 用户反馈,private method 打桩的价值也很大,我们就自研了定制的反射包 creflect,而穿越 reflect 包的限制,成功支持了 private method。一些想使用 private method 特性的用户,可能会误使用 ApplyMethod 接口,导致错误,而提供该特性的扩展接口是 ApplyPrivateMethod。

示例代码:有了 ApplyPrivateMethod 接口后,可以跨包给私有方法打桩,第二层有两个 convey,说明有两个用例,第一个用例针对 private pointer method,第二个用例针对 private value method。

func TestApplyPrivateMethod(t *testing.T) {
    Convey("TestApplyPrivateMethod", t, func() {
        Convey("patch private pointer method in the different package"func() {
            f := new(fake.PrivateMethodStruct)
            var s *fake.PrivateMethodStruct
            patches := ApplyPrivateMethod(s, "ok"func(_ *fake.PrivateMethodStruct) bool {
                return false
            })
            defer patches.Reset()
            result := f.Happy()
            So(result, ShouldEqual, "unhappy")
        })

        Convey("patch private value method in the different package"func() {
            s := fake.PrivateMethodStruct{}
            patches := ApplyPrivateMethod(s, "haveEaten"func(_ fake.PrivateMethodStruct) bool {
                return false
            })
            defer patches.Reset()
            result := s.AreYouHungry()
            So(result, ShouldEqual, "I am hungry")
        })
    })

}

如果你想进一步了解 private method 特性,请阅读笔者之前写的一篇文章《gomonkey支持为private method打桩了》[7]

func 惯用法刷新

刷新7:当为 func 打桩时可以直接指定返回值

要使用该特性,就不能再使用 ApplyFunc 接口了,而是使用 ApplyFuncReturn 接口。

示例代码:ApplyFuncReturn 接口从第二个参数开始就是桩的返回值。

func TestApplyFuncReturn(t *testing.T) {
    Convey("TestApplyFuncReturn", t, func() {
        Convey("declares the values to be returned"func() {
            info := "hello cpp"
            patches := ApplyFuncReturn(fake.ReadLeaf, info, nil)
            defer patches.Reset()
            for i := 0; i < 10; i++ {
                output, err := fake.ReadLeaf("")
                So(err, ShouldEqual, nil)
                So(output, ShouldEqual, info)
            }
        })
    })
}

func var 惯用法刷新

刷新8:当为 func var 打桩时可以直接指定返回值

要使用该特性,就不能再使用 ApplyFuncVar 接口了,而是使用 ApplyFuncVarReturn 接口。

示例代码:ApplyFuncVarReturn 接口从第二个参数开始就是桩的返回值。

func TestApplyFuncVarReturn(t *testing.T) {
    Convey("TestApplyFuncVarReturn", t, func() {
        Convey("declares the values to be returned"func() {
            info := "hello cpp"
            patches := ApplyFuncVarReturn(&fake.Marshal, []byte(info), nil)
            defer patches.Reset()
            for i := 0; i < 10; i++ {
                bytes, err := fake.Marshal("")
                So(err, ShouldEqual, nil)
                So(string(bytes), ShouldEqual, info)
            }
        })

    })
}

constructor 惯用法刷新

很多时候,我们先使用 Apply 族函数接口完成一个目标对象的打桩,它返回一个  patches 对象,然后我们再使用 Apply 族方法接口完成其他目标对象的打桩。

示例代码:测试用例中需要对两个函数 (fake.Exec 和 json.Unmarshal) 都进行打桩,我们分别调用 ApplyFunc 接口完成打桩。

func TestIndependent(t *testing.T) {
    Convey("TestIndependent", t, func() {
        Convey("two funcs"func() {
            patches := ApplyFunc(fake.Exec, func(_ string, _ ...string) (string, error) {
                return outputExpect, nil
            })
            defer patches.Reset()
            patches.ApplyFunc(json.Unmarshal, func(data []byte, v interface{}) error {
                p := v.(*map[int]int)
                *p = make(map[int]int)
                (*p)[1] = 2
                (*p)[2] = 4
                return nil
            })
            output, err := fake.Exec("""")
            So(err, ShouldEqual, nil)
            So(output, ShouldEqual, outputExpect)

            var m map[int]int
            err = json.Unmarshal(nil, &m)
            So(err, ShouldEqual, nil)
            So(m[1], ShouldEqual, 2)
            So(m[2], ShouldEqual, 4)
        })
    })
}

刷新9:当打桩接口统一时可以批处理

我们先构造一个 patches 对象,然后通过批处理完成打桩。

示例代码:

func TestBatch(t *testing.T) {
    Convey("TestBatch", t, func() {
        Convey("two funcs"func() {
            patchPairs := [][2]interface{}{
                {
                    fake.Exec,
                    func(_ string, _ ...string) (string, error) {
                        return outputExpect, nil
                    },
                },
                {
                    json.Unmarshal,
                    func(_ []byte, v interface{}) error {
                        p := v.(*map[int]int)
                        *p = make(map[int]int)
                        (*p)[1] = 2
                        (*p)[2] = 4
                        return nil
                    },
                },
            }
            patches := NewPatches()
            defer patches.Reset()
            for _, pair := range patchPairs {
                patches.ApplyFunc(pair[0], pair[1])
            }
            output, err := fake.Exec("""")
            So(err, ShouldEqual, nil)
            So(output, ShouldEqual, outputExpect)

            var m map[int]int
            err = json.Unmarshal(nil, &m)
            So(err, ShouldEqual, nil)
            So(m[1], ShouldEqual, 2)
            So(m[2], ShouldEqual, 4)
        })
    })
}

刷新10:当打桩操作可复用时封装 fake 关键字

常见的 fake 关键字包括 DB,HTTP,AMQP 和 K8S 等,可以通过 DDD 的六边形架构来完整识别。还有一些 fake 关键字,对应标准库函数操作,比如 随机数 RandInt。

我们封装 fake 关键子时,如果需要打桩,那么需要将 patches 对象传入。

示例代码:通过 FakeRandInt 函数实现了 fake 关键字 RandInt,将 gomonkey 的打桩接口封装起来,非常通用,可以在所有与随机数打桩相关的用例中复用。

func FakeRandInt(patches *Patches, randomNumbers []int) {
    var outputs []OutputCell
    for _, rn := range randomNumbers {
        outputs = append(outputs, OutputCell{Values: Params{rn}})
    }
    patches.ApplyFuncSeq(rand.Intn, outputs)
}

示例代码:对于 fake 关键字 RandInt 的使用,用户不需要关注 gomonkey 特性的具体使用方法,仅仅注入 patches 对象和随机数切片就可以完成随机数生成的通用打桩。

func TestGenerateAnswerByOnce(t *testing.T) {
    Convey("Given the system random number is 1964", t, func() {
        patches := NewPatches()
        FakeRandInt(patches, []int{1964})
        defer patches.Reset()
        Convey("When generate answer"func() {
            answer := generateAnswer()
            Convey("Then the answer is 1964"func() {
                So(answer, ShouldEqual, "1964")
            })
        })
    })
}

func TestGenerateAnswerBySeveralTimes(t *testing.T) {
    Convey("Given the system random number seq is [788, 2260]", t, func() {
        patches := NewPatches()
        FakeRandInt(patches, []int{7882260})
        defer patches.Reset()
        Convey("When generate answer"func() {
            answer := generateAnswer()
            Convey("Then the answer is 7826"func() {
                So(answer, ShouldEqual, "7826")
            })
        })
    })
}

小结

这一年, gomonkey 社区快速发展,使得 Go 语言打桩工作变得越来越美好,受到了国内外 gopher 的广泛赞赏和肯定。

为了让更多的 gopher 低成本受益,笔者特意总结了 gomonkey 惯用法的十大刷新,希望读者可以快速掌握,并能及时将学到的技能应用到开发者测试的具体实践中去,使得测试用例的开发效率和表达力都进一步得到提升。

参考资料

[1]

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

[2]

monkey: https://github.com/bouk/monkey

[3]

猴子补丁原理: https://bou.ke/blog/monkey-patching-in-go/

[4]

《gomonkey 全面支持 arm64 了》: https://www.jianshu.com/p/59d5ccf3fcb1

[5]

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

[6]

《GoMock框架使用指南》: https://www.jianshu.com/p/f4e773a1b11f

[7]

《gomonkey支持为private method打桩了》: https://www.jianshu.com/p/7546e788613b



推荐阅读


福利

我为大家整理了一份从入门到进阶的Go学习资料礼包,包含学习建议:入门看什么,进阶看什么。关注公众号 「polarisxu」,回复 ebook 获取;还可以回复「进群」,和数万 Gopher 交流学习。

推荐阅读