你该刷新gomonkey的惯用法了
2024-04-09 16:16:03  阅读数 3644

引言

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

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

gomonkey.png

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

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

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

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

letter.png

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

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

gomonkey 惯用法刷新

gomonkey 基础特性列表如下:

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

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

interface 惯用法刷新

之前很多 gopher 习惯使用 GoMock 框架对 interface 进行打桩,笔者当时也写了一篇文章《GoMock框架使用指南》。后来有一些 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打桩了》

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{788, 2260})
        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 惯用法的十大刷新,希望读者可以快速掌握,并能及时将学到的技能应用到开发者测试的具体实践中去,使得测试用例的开发效率和表达力都进一步得到提升。