Dai Chao

fordring866@gmail.com | Put your faith in the Light!

学习编写单元测试

这两周的工作主要是给自己之前写的代码填坑,也就是写单元测试。

我做单元测试的方法

裸测很难搞

单元测试(unit testing)是指对软件中的最小可测试单元进行检查和验证,而我要测试的内容是一些用Go语言写的接口,大概长这个样子:

func (g *Foo) ObjPQ(obj *Obj, filterPolicy []string, scorePolicy map[string]int) PQ {
	filteredContainerList, _, _ := g.Filter(obj, filterPolicy)
	scoredContainerList, _ := g.Score(filteredContainerList, obj, scorePolicy)
	containerPQ := NewPriorityQueue()
	for _, container := range scoredContainerList {
		newPQ.Insert(container.Name, container.Score)
	}
	return containerPQ
}

有编程基础的同学很容易理解这个函数的用意。大致的意思是说,我要为一些Obj对象找到最适合它的容器,于是先过滤掉不适合Obj的容器,再对剩下的容器打分,然后按照分数放到优先队列里,方便后面取用。

按照我以前的思路,测试这个函数当然是先编一些数据,代入验算再说。

import "testing"
func TestObjPQ(t *testing) {
	<做测试>
}

然而我很快就意识到,我的函数背后是庞大的数据库和更低层的接口。无论是“过滤”还是“打分”,都需要运行在这一堆数据上面。如果只编造参数,我无从手动预测正确结果;如果连背后的数据也模拟一遍,那成本又太大了,因为我很难估计下层接口、下下层接口又要用到什么数据。

正当我一筹莫展之际,经验丰富的导师给我介绍了”monkey patching”的概念,并塞给了我一个链接

Patching大法好

按照我目前的理解,monkey patching就是重新定义一个函数,从而让它返回任何你想要的值。我现在不是预测不了过滤和打分的结果吗?patch一下就可以为所欲为了!如代码所示,我先用monkey.PatchInstanceMethod把一个方法屏蔽为自定义的函数,事后再用monkey.UnpatchAll()解除屏蔽。这样一来,我既免去了编数据的负担,又能专注于函数本身而不用担心底层接口是否可靠。一举两得,岂不美哉?

import "reflect"
import "testing"
import "github.com/bouk/monkey"
import "testing"
func TestObjPQ(t *testing) {
	monkey.PatchInstanceMethod(reflect.TypeOf(g), "Filter", func(*Obj,[]string) []Container {
		return 任何我想要的过滤结果
	})
	monkey.PatchInstanceMethod(reflect.TypeOf(g), "Score", func([]Container, *Obj, []string) []ScoredContainer {
		return 任何我想要的打分结果
	})
	<做测试>
	monkey.UnpatchAll()
}

用GoConvey包装测试语法

不多说了,直接看例子。这是矮穷矬的测试代码:

func TestFunc(t *testing.T) {
	type args struct {
		target     reflect.Type
		methodName string
	}
	tests := []struct {
		name string
		args args
		want int
        wantErr bool
	}{
		{"Good case", data[0], 0, false},
        {"Bad case", data[1], -1, true},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
            got, gotErr := Func(tt.args.target, tt.args.methodName)
            if (gotErr != nil) != tt.wantErr {
				t.Errorf("Func() error = %v, wantErr %v", gotErr, tt.wantErr)
				return
			}
			if got != tt.want {
				t.Errorf("Func() = %v, want %v", got, tt.want)
			}
		})
	}
}

这是高富帅的测试代码:

func TestFunc(t *testing.T) {
    Convey("Good case", t, func() {
        res, err := Func(data[0])
        So(err, ShouldBeNil)
        So(res, ShouldEqual, 0)
    })
    Convey("Bad case", t, func() {
        _, err := Func(data[1])
        So(err, ShouldNotBeNil)
        So(err.Error(), ShouldContainSubstring, "timeout")
    })
}

有人问:“高富帅先生,请问您保养头发的秘诀是什么?为什么21岁了还是如此乌黑浓密呢?”

高富帅轻轻一笑,从容答道:“因为我用goconvey。”

震惊!800⭐️的GitHub项目居然过不了作者自己写的测试!

写测试从不怕测出问题,因为写测试就是为了找出问题。

写测试最怕的是测试本身出了问题。

最最怕的是测试引用的第三方库出了问题。

最最最怕的是,打开那个库然后运行作者自己写的单元测试,居然过不了。

似有似无的bug

前面提到了用PatchInstanceMethod屏蔽函数的函数,但它对应的解蔽函数UnpatchInstanceMethod莫名其妙地失效了。经过一番调查,发现程序记录了一个类型为map[reflect.Value]patch的映射表。patch的时候,程序调用reflect.MethodByName,通过名字找到函数的值(类型为reflect.Value),作为key存进表中。unpatch的时候,又以同样的方式试图找到key,这时却失败了。

两次返回的值为什么会不一样?我用fmt.Printf("%v\n", value)把它们打印了出来:

monkey_test.go: patch: 0x12c1670
monkey_test.go: unpatch: 0x12c1670

看上去简直毫无问题,但是reflect.Value是一个复合的结构体,这里打印的应该是它各个属性的值,怎么会得到一个地址呢?这其中必有蹊跷!

reflect.Value里都有些啥

// Value is the reflection interface to a Go value.
//
// To compare two Values, compare the results of the Interface method.
// Using == on two Values does not compare the underlying values
// they represent.
type Value struct {
	// typ holds the type of the value represented by a Value.
	typ *rtype
	// Pointer-valued data or, if flagIndir is set, pointer to data.
	ptr unsafe.Pointer
	flag
}

打开go/src/reflect/value.go,我看到了Value结构体的内部实现。原来,Go语言用Value存储一个“值”的信息。“值”不只是数据,还包括类型和其他元信息,因此要用结构体表示。而ptr只是一个指针,它最终指向的才是真正的“underlying value”。也就是说,MethodByName之所以会返回不同的Value,很可能是因为这里的ptr。而刚才打印结果之所以相同,又可能是因为Printf函数对打印Value有特殊的处理方式——也许只打印了它的地址罢了。

经过实验证实,每一次MethodByName返回的ptr确实不同,但它们指向的值却相同。

为什么?

不说过程了,直接上调用关系(有所改编):

// Method结构包含了属性Func,也就是我们要找的Value
reflect/type.go: 870: func (t *rtype) MethodByName(name string) (m Method, ok bool) {
    return t.Method(eidx), true
}
reflect/type.go: 836: func (t *rtype) Method(i int) (m Method) {
    methods := t.exportedMethods()
    p := methods[i]
    m.Type = mt
	tfn := t.textOff(p.tfn)    // 临时变量tfn。这里只用知道它是临时变量就好了,展开讲textOff太麻烦,我自己都没看懂。
	fn := unsafe.Pointer(&tfn) // fn取的是tfn的地址!
	m.Func = Value{mt.(*rtype), fn, fl} // 最终返回的Value将fn作为ptr属性的值
	m.Index = i
	return m
}

可以看到最终返回的Value.ptr竟来自一个不知从哪跑来的野孩子!怪不得每次都返回不一样的值!关于这一点文档却没有作出说明……

……好吧,文档也没想让作者用这种骚操作强行拿到私有变量,大家扯平了🐒

// 模仿reflect.Value构造盗版结构体
type value struct {
	_   uintptr
	ptr unsafe.Pointer
}
// 强行把reflect.Value的指针转为盗版指针,然后取其ptr
func getPtr(v reflect.Value) unsafe.Pointer {
	return (*value)(unsafe.Pointer(&v)).ptr
}

实际上这个库的作者还是很强大的,17岁代表荷兰参加IOI,写这个库玩底层hacking的时候才19岁……想想自己,21岁了还在写最基础的单元测试……感兴趣的朋友可以去GitHub看看,整个库只有五六百行,但实现得很巧妙。出现bug也只是因为随着Go语言不断进化,之前的底层hacking也需要更新了,仅此而已……

然后我就蹭到了一个800🌟repo的contribution,耶!

坑爹的fmt.Printf(“%v”)

最后,关于fmt.Printf怎么处理%v标记,为什么Value只打印一个地址,来看看官方文档怎么说:

The default format for %v is:

bool: %t

int, int8 etc.: %d

…(讲别的基本类型怎么打印)

…(讲结构体怎么打印,我看到这里就没看了)

…(讲如何保留几位小数)

…(讲打印字符串的特殊情况)

…(讲怎么打印复数)

…(讲附加的打印标志)

…(接着讲附加的打印标志)

…(正常人根本不会看到这里)

…(SURPRISE MOTHERF**KER)

Except when printed using the verbs %T and %p, special formatting considerations apply for operands that implement certain interfaces. In order of application:

1. If the operand is a reflect.Value, the operand is replaced by the concrete value that it holds, and printing continues with the next rule.

🙂🙂🙂

缺点

做单元测试的时候,patching是非常必要的工具。如果你想测试main函数却坚决不用patching,那你就是测试界的鬼才。然而,patching虽能简化底层接口,但它们往往会以“微妙”的方式影响程序整体,而且让你很难察觉。回头看看最开始的例子,我为了方便直接把过滤和打分的算法屏蔽了,但如果它们本来要更新一些全局状态呢?如果我后面刚好又要用到这些状态呢?那我是不是还得钻到里头去看个究竟,才能确保不出问题?所以说,接口之间并不会完全泾渭分明,总会相互干涉一些。单元测试的思想是“头痛医头、脚痛医脚”,这显然是有局限性的。

单元测试是用来debug的,可要是单元测试本身有bug那就很难受了。一旦出了问题,不仅要检查被测试的函数,还要检查测试代码,岂不是很尴尬?(小声BB:其实是因为我自己太菜了,写啥都出bug)

编数据真的好烦!一不小心编错了更烦!一个结构体50多个属性!一个个地编!编几十个!属性之间有关联还不能乱编!改一个属性得把它关联的全都改了!自己写生成算法又太麻烦了(写错了怎么办?你来帮我debug这个用来为debug函数生成debug数据的函数吗?)