Go使用泛型后,这么写单元测试会失效

2022年8月25日 300点热度 0人点赞 0条评论

Go 在引入泛后,之前我在 Go单元测试通关指南里给大家介绍的全能打桩工具 Go Monkey 就没法对泛型函数进行打桩了?今天这篇文章给大家举几个例子说下为什么失效,以及作者对这一问题未来解决的展望,文章转载自「Go 招聘」,平时关注Go工作内推机会的可以多关注号主的信息。

问题的由来

gomonkey[1] 社区,用户ericuni 提了一个 issue[2],如下图所示:

图片issue-95.png

泛型是Go1.18引入的一个新特性,笔者在本地升级Go版本到Go1.18,复现了问题。

(1)产品代码示例:

func Add[T int | float32 | float64](a T, b T "T int | float32 | float64") T {
    return a + b
}

网管在这里乱入一下,看完这个代码觉得Go这个泛型写法是真的蠢,希望后面官方能改进下吧,我不希望代码规范里以后出现一条,尽量少用Go的泛型?。

(2)测试代码示例:

func TestGenericFuncPatch(t *testing.T) {
    Convey("TestGenericFuncPatch", t, func() {
        So(Add[int](12 "int"), ShouldEqual, 3)
        So(Add(12), ShouldEqual, 3)
        patches := ApplyFuncReturn(Add[int], 5)
        defer patches.Reset()
        So(Add(12), ShouldEqual, 5)
    })
}

(3)执行该测试用例,复现问题,打桩未生效
测试用例执行结果:

=== RUN   TestGenericFuncPatch

  TestGenericFuncPatch ✔✔✘


Failures:

  * /Users/zhangxiaolong/Desktop/D/go-workspace/gostudy/framework-study/goconvey-study/slice/slice_test.go
  Line 203:
  Expected: '5'
  Actual:   '3'
  (Should be equal)


3 total assertions

--- FAIL: TestGenericFuncPatch (0.00s)
FAIL
FAIL    goconvey-study/slice    0.010s

说明:

  • Add[int](1, 2 "int")Add(1, 2)两种写法等价,但Add(1, 2)这种写法更方便,因为这种写法使用了 Go 泛型类型实参的自动推导,从而不用显式的手动指定类型实参,实际上是编译器帮我们完成了相关工作;
  • 通过 gomonkey 的ApplyFuncReturn接口完成对函数Add[int]的打桩后,再次调用Add(1, 2),发现返回值仍然是3而不是期望的5,说明对该函数的打桩并未生效

解决方案

每次产品代码中实例化泛型函数的过程,比如Add[int],对应的汇编代码是一个函数调用,所以对实例化后的泛型函数打桩就变成了对函数调用打桩了。然而,每次产品代码中实例化泛型函数的时候,对应的汇编代码中的函数调用是不同的函数名字,比如func1, func2, ...等,这会导致虽然打桩当时成功了,但后续再实例化泛型函数的时候,对应的函数调用名称就变成了其他的,所以打桩没有真正生效,比如打桩打在了func2上,而之后的调用却发生在func3, func4上了。

其实,实例化泛型函数过程中产生的func1, func2, ...等都是中间函数,真正的泛型函数实例会在中间函数层被最终调用。不清楚Go语言为啥要这样设计泛型的实现,但它肯定有深层次的原因。我们只能从中间函数的代码段逐条解析,找到CALL指令后提取参数来计算泛型函数实例化后的真正地址。

找到泛型函数实例的真正地址后,就确定了真正的目标函数,但桩函数如何写?

泛型函数会占用AX、BXCX 三个寄存器来保存参数,而普通函数仅占用AXBX两个寄存器来来保存参数,同时泛型函数的第一个入参并不在AX中,而普通函数的第一个入参却在AX中,所以桩函数必须和泛型函数长的一模一样才行。

还有一点,空接口通过反射无法区分是泛型函数还是普通函数,所以泛型函数打桩接口中要通过bool值来区分到底是泛型函数还是普通函数,这就导致接口不同。

综上,泛型函数打桩接口与普通函数不同,同时泛型函数需要特殊获取目标函数的地址,到了函数跳转时刻,泛型函数的桩函数使用的寄存器个数与普通函数的桩函数不同,从而汇编代码不同,几乎是两套代码。

考虑到泛型的使用场景并不广泛,同时也有替代方案(比如接口+反射),而要完整的支持泛型打桩却需要为 gomonkey 引入很高的复杂度,因此不是很划算,并且 Go 语言泛型机制也在不断的演进,或许后面再支持泛型打桩就变得很容易了。

于是我们计划在 gomonkey 框架层面暂时不支持对泛型打桩,但是 gomonkey 用户或多或少还是会有对泛型打桩的诉求,那么这种情况下应该怎么办是一个值得思考的问题。

如果让中间函数变成唯一,那么对中间函数打桩就是对泛型打桩。这时,我们想到了函数变量,可以规避直接对泛型打桩带来的一系列问题。

典型案例

我们引入函数变量,将上述复现问题的案例打造成泛型函数打桩的典型案例,供大家参考!

(1)产品代码中增加函数变量的定义

func Add[T int | float32 | float64](a T, b T "T int | float32 | float64") T {
    return a + b
}

// 函数变量的定义
var AddInt = Add[int]

(2)在测试代码中对函数变量进行打桩

func TestGenericFuncPatch(t *testing.T) {
    Convey("TestGenericFuncPatch", t, func() {
        So(AddInt(12), ShouldEqual, 3)
        // 也可以使用 ApplyFuncVar 接口,但此处使用 ApplyFuncVarReturn 接口更简洁
        patches := ApplyFuncVarReturn(&AddInt, 5)
        defer patches.Reset()
        So(AddInt(12), ShouldEqual, 5)
    })

}

(3)执行该测试用例,结果符合预期
测试用例执行结果:

=== RUN   TestGenericFuncPatch

  TestGenericFuncPatch ✔✔


2 total assertions

--- PASS: TestGenericFuncPatch (0.00s)

案例延伸

gomonkey 既然对泛型函数可以打桩了,那么对泛型方法是否也可以打桩呢?

很不幸,目前 Go 的方法并不支持泛型,如下:

type A struct {
}

// 不支持泛型方法
func (receiver A) Add[T int | float32 | float64](a T, b T "T int | float32 | float64") T {
    return a + b
}

但是因为receiver支持泛型, 所以如果想在方法中使用泛型的话,目前唯一的办法就是曲线救国,迂回地通过receiver使用类型形参:

type A[T int | float32 | float64struct {
}

// 方法可以使用类型定义中的形参 T 
func (receiver A[T]) Add(a T, b T) T {
    return a + b
}

// 用法:
var a A[int]
a.Add(12)

var aa A[float32]
aa.Add(1.02.0)

打桩过程与之前泛型函数类似,供大家参考!

(1)产品代码中还是增加函数变量的定义

type A[T int | float32 | float64struct {
}

// 方法可以使用类型定义中的形参 T 
func (receiver A[T]) Add(a T, b T) T {
    return a + b
}

// 函数变量的定义
var AObject = new(A[int])
var SAddInt = AObject.Add

(2)在测试代码中对函数变量进行打桩

func TestGenericMethodPatch(t *testing.T) {
    Convey("TestGenericMethodPatch", t, func() {
        So(SAddInt(12), ShouldEqual, 3)
        patches := ApplyFuncVarReturn(&SAddInt, 5)
        defer patches.Reset()
        So(SAddInt(12), ShouldEqual, 5)
    })
}

(3)执行该测试用例,结果符合预期
测试用例执行结果:

=== RUN   TestGenericMethodPatch

  TestGenericMethodPatch ✔✔


2 total assertions

--- PASS: TestGenericMethodPatch (0.00s)

小结

用户期望可以在框架层面解决一切共性问题,这本属于合理的诉求,但现实有时很骨感,在种种约束下必须改变思路,而在用户侧较优雅的解决相关问题也是一种不错的选择。

本文针对 gomonkey 用户,给出了如何对泛型进行打桩的解决方案,并提供了典型案例及其延伸,希望对读者有一定的帮助!

参考资料

[1]

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

[2]

issue: https://github.com/agiledragon/gomonkey/issues/95

- END -


扫码关注公众号「网管叨bi叨」

给网管个星标,第一时间吸我的知识 ?

网管整理了一本《Go 开发参考书》收集了70多条开发实践。去公众号回复【gocookbook】领取!还有一本《k8s 入门实践》讲解了常用软件在K8s上的部署过程,公众号回复【k8s】即可领取!


觉得有用就点个在看  ???

85590Go使用泛型后,这么写单元测试会失效

这个人很懒,什么都没留下

文章评论