go-assert:如何自动输出上下文变量信息
断言失败时自动打印 buf、s1、s2 的值:go-assert 如何借助 DWARF 调试信息拿到上下文变量。
- #go
- #testing
- #go-assert
- #reflection
闲来写写码,顺便做一下实现细节的记录。前文 go-assert 库介绍 简单说明了 github.com/huandu/go-assert 库的最核心设计思路和功能,这次来介绍这个库更加有趣的功能:自动输出上下文变量。
背景
我们写测试用例的时候有一个常见的需求,就是在用例出错的时候打印跟用例相关的变量。
例如,在下面代码里面,如果万一 assert 失败了,我们很希望能够自动把 buf、s1、s2 的值打印出来。
func TestBuffer(t *testing.T) {
a := assert.New(t)
s1 := "foo"
s2 := "bar"
buf := bytes.NewBufferString(s)
buf.WriteString(s2)
// 如果下面 assert 失败了,能够自动将 buf、s1、s2 的值打印出来么?
a.Assert(buf.Len() == len(s1) + len(s2))
}
经过一些研究发现,这个功能是完全可行的,只是需要一些额外条件。
Gopwt 的实现方法
在实现任何功能之前,都应该充分寻找类似的代码,看是否已经有人将类似的想法做出来了,避免实现一个没什么特色的功能。
经过搜索,我看到 github.com/ToQoz/gopwt 这个很神奇的库,它能实现的效果非常惊艳。
func TestMain(m *testing.M) {
flag.Parse()
gopwt.Empower()
os.Exit(m.Run())
}
func TestFoo(t *testing.T) {
a := "xxx"
b := "yyy"
assert.OK(t, a == b, "a should equal to b")
}
执行 go test 之后会输出如下错误信息:
--- FAIL: TestFoo (0.00s)
assert.go:85: FAIL main_test.go:20
assert.OK(t, a == b, "a should equal to b")
| | |
| | "yyy"
| false
"xxx"
Assertion messages:
- a should equal to b
--- [string] b
+++ [string] a
@@ -1,1 +1,1@@
-yyy
+xxx
效果相当的理想。如果这个库已经十分完美了,那么我就没有任何必要再重复造这个轮子了。我阅读了所有代码,了解了这个库的各种实现细节。
从原理上说,gopwt 思路很清晰:
Gopwt 的所有技巧都藏在 TestMain 里面的 gopwt.Empower() 这个函数里。Empower 函数通过 go/ast 解析了当前目录的所有 *_test.go 文件,识别出 assert.OK 等函数调用,将所有这些调用都进行了代码改写,将里面所有变量都提取出来一一保存其值,并最终调用 github.com/ToQoz/gopwt/translatedassert.OK 等函数,完成真正的打印输出。
这里面有几个关键点:
- 我们手写的那些测试用例函数其实根本没有执行,
gopwt.Empower()这个函数其实永远不会返回,它将直接调用os.Exit返回错误码; - Gopwt 会将当前目录所有源文件拷贝到一个临时目录,方便后续改写代码;
- Gopwt 改写完代码后,通过在临时目录里面执行
go test来得到我们所看到的测试结果。
这样做的好处就不多说了,效果确实不错,这里着重说一下这个方案的局限性。
首先能看到,这个库已经很久没更新,很大原因是这种方案很难适应当前 go mod 的要求。有了 go mod 之后,gopwt 就无法通过仅仅拷贝一个目录的代码来实现这个功能,而不得不从 go.mod 文件所在目录开始一起拷贝,假如项目目录非常巨大就会耗费大量时间。更糟糕的是,这个拷贝、改写和编译代码的过程是算在 go test 执行时限里面的,像 VS Code 里面 Go 插件就会将测试执行时限限定在 30s,显然这个时间有点不太够用。
其次,由于 gopwt 依赖于代码拷贝,那么最基本的就是需要将所有必要的代码都拷贝过去。普通的 Go 项目倒还好说,但如果项目中还包含 cgo 代码、额外的 make file、在 go mod 里面通过 replace 相对路径替换的库等,要想正确的拷贝所有的代码还真有些难度。
最后,使用 gopwt 之后也不太方便调试测试用例,比如开着调试器执行 go test -c 得到的二进制,会因为 gopwt 干扰而无法调试。当然,gopwt 提供了 GOPWT_OFF 这个环境变量来控制是否启用这个库。
DWARF 调试信息
Gopwt 既然有那么多问题,特别是在使用 go mod 的 Go 项目中不太好使,那么肯定还得另辟蹊径。
我很喜欢 gopwt 的输出结果,那么有没有不改写测试代码也不侵入测试代码的方法实现这个功能呢?我第一个想到的是通过调试器的 DWARF 信息来拿到调用函数栈内所有变量。
这种做法理论上是可行的,我们可以相对容易的拿到测试函数的 PC 值和栈顶位置,通过 DWARF 里面的变量信息可以得到变量在栈上面的真实地址,结合分析代码得到的变量类型信息即可还原任何变量。
不过这个做法还是很有局限性,Go 编译器会将不少变量优化到寄存器里面去,比如一些循环变量,或者还会将一个没有逃逸的 struct 值分散的放在栈上,用来做到更好的字节对齐,这对我恢复变量信息带来了巨大困难。
最糟糕的是,也许出于有编译速度的目的,go test 默认编译的二进制是不包含 DWARF 信息的,这让这件事情一开始就无法进行了,遂不得不放弃。
增加 Use 方法
看来无侵入的方案暂时还想不到,那么我就只能思考怎么用相对侵入少的方案实现类似功能了。
结合之前实现 Assert 相关函数打印源码的思路,打印 Assert 中用到的变量比较容易,通过直接分析源码中包含的变量名即可。
例如下面的代码。
Assert(buf.Len() == len(s1) + len(s2))
通过分析源码拿到 buf.Len()==len(s1)+len(s2)对应的 AST,用 ast.Inspect方法遍历这段代码并找到所有的 ast.Ident 结点就能分析出其中用到了 buf、s1 和 s2 这几个变量。
接下来问题就简单了,如果我们能实现告诉框架这几个变量的值,那么在需要输出的地方就可以正常的输出了。
一种最简单的做法是定义下面的函数,让调用者自行调用。
func Use(vars map[string]interface{})
Use(map[string]interface{}{
"buf": &buf,
"s1": &s1,
"s2": &s2,
})
很显然,"buf" 等字符串信息是非常冗余的,既然我们都分析了源码,得知 buf 的名字了,为什么还要让调用者多此一举呢,直接通过分析调用 Use 的代码就能得到变量名字,极大简化调用者的负担,因此最终 Use 设计成这样。
func (a *A) Use(args ...interface{})
具体调用的例子可以参考 A.Use 文档,具体代码可以参考 a.go 相关代码。
小结
为了能让 go-assert 提供更多的上下文信息输出,在调研了各种可能之后,发现最可行的方法是提供一个 Use 方法,手动注入需要跟踪的变量。这个思路最终看起来并没有那么的黑科技,不过已经是当前条件下比较优的一个方案,实际使用感受还是挺好的。
本文最初于 2020-05-14 发布在知乎。