go源码剖析-gotest实现
Posted 2020-02-23 16:54 +0800 by ZhangJie ‐ 2 min read
问题背景
在go1.13出来后不久,有不少同学遇到了go test报错的问题 “flag -test.timeout provided but not defined”。这个问题是如何引起的呢?
- 公司微服务代码,通常是借助代码生成工具统一生成的,包括针对接口的测试用例;
- 在生成单元测试文件时,如helloworld_test.go中,在该文件中定义了一些测试选项如-timeout、-service、-method、-target、-req、-rsp等; 上述定义的选项,在helloworld_test.go中的func init()中执行flag.Parse()操作完成选项解析。
这是被测试代码的一点背景信息,上述测试代码在go1.12中是没有问题的,但是当升级到go1.13后,就出现了上述“flag … provided but not defined”的错误。
实现细节
go test实现细节,需要跟踪一下命令go test的执行过程,具体对应这个源文件:src/cmd/go/internal/test/test.go。
假如现在,我们创建一个package名为xxxx的go文件,然后创建一个package名为xxxx_test的_test文件,如:
file: helloworld_test.go
package xxxx_test
import "testing"
import "xxxx"
func TestHelloWorld(t *testing.T) {
xxxx.Hello()
}
/*
func init() {
flag.Parse()
}
*/
/*
func TestMain(m *testing.M) {
os.Exit(m.Run())
}
*/
file: helloworld.go
package xxxx
func Hello() {
}
这里的实例代码,做了适当的简化,方便大家查看。为了更好地跟踪go test过程,我们可以以调试模式运行go test,如GOTMPDIR=$(pwd)/xxx dlv exec $(which go) -- test -c
:
- 首先指定了临时目录GOTMPDIR为当前目录下的xxx,在执行编译构建过程中的临时文件将生成到该目录下;
- dlv执行的时候,在
--
后面添加传递给被调试程序的命令行参数,如这里传递给go的参数是test -c
;
此外,我们可以执行fswatch $(pwd)/xxx
来跟踪文件系统的变化,从而帮助我们分析go test到底干了什么,这样比较直观,直接看源码,代码量有点多,容易抓不住头绪。
接下来只需要执行next、next、next步进的形式执行go test的代码逻辑就可以了。过程中,我们看到fswatch输出了如下信息:
zhangjie@knight test $ fswatch .
/Users/zhangjie/test/test/xxx/go-build3964143485
/Users/zhangjie/test/test/xxx/go-build3964143485/b001
/Users/zhangjie/test/test/xxx/go-build3964143485/b001/_testmain.go
此时查看下_testmain.go的文件内容:
file: _testmain.go
// Code generated by 'go test'. DO NOT EDIT.
package main
import (
"os"
"testing"
"testing/internal/testdeps"
_ "xxxx"
_xtest "xxxx_test"
)
var tests = []testing.InternalTest{
{"TestHelloWorld", _xtest.TestHelloWorld},
}
var benchmarks = []testing.InternalBenchmark{
}
var examples = []testing.InternalExample{
}
func init() {
testdeps.ImportPath = "xxxx"
}
func main() {
m := testing.MainStart(testdeps.TestDeps{}, tests, benchmarks, examples)
os.Exit(m.Run())
}
上述文件中包含了一个main函数,是go test -c生成的测试程序的入口函数。
上述文件中,import了我们自己编写的两个package,如
import _ "xxxx"
,以及import _xtest "xxxx_test"
,这两个package的代码就是我们上面给出的,一个Hello函数定义,一个对Hello函数的单元测试,没有什么复杂的。在helloworld_test.go中我们注释掉了两段代码,一个是func init()逻辑,一个是func TestMain()逻辑。我们稍后再说这个。func init()中也没有什么需要关注的。
func main中,先执行了一个testing.MainStart(…)初始化逻辑,这里面赶了什么呢?它执行了一个testing.Init()函数,来初始化go testing这个package中自定义的一些flags,如-test.timeout之类的。主意这些flags的注册逻辑是在所有package的func init()执行之后才发起的。
func main中,接着执行了一个os.Exit(m.Run())来执行测试,展开m.Run()能够看到根据-test.run选择性运行测试用例,或执行所有测试用例的逻辑。注意,当我们在测试文件中定义了TestMain方法之后,这里生成的代码就不是os.Exit(m.Run())了,而是_xtest.TestMain(m),这将允许先执行我们自己的测试代码设置逻辑。如在TestMain中执行一些准备测试数据、工作目录、注册命令选项 逻辑。
该问题产生原因
好,事情至此,我们先来解答本文开头遇到的问题?
- go1.13中对testing package的初始化逻辑做了一点调整,它将flags的初始化逻辑放在了main程序中,所有的其他package的func init()执行之后;
- go官方其实不建议在func init()中定义一些flags的,除非是main package。但是我们很多开发并不了解这个背景,经常在func init()中定义一些flags并Parse,甚至是在_test.go文件中;
- go1.13做了上述调整之后,在func init()中执行flag.Parse()时,如果go test传递了一些还没有来得及注册的选项,如-test.timeout是在func main()执行后注册,就会报错"flag -test.timeout provided but not defined"。
到这,我们解释了问题产生的原因了。
如何规避该问题
现在,我们再来看下如何规避上述问题,有些情况下,确实有需要在_test.go中定义一些flags进行精细化控制的情况。
我们了解到,如果我们自定义了TestMain函数,go test就会生成这样的代码:
file: _testmain.go
func main() {
m := testing.MainStart(testdeps.TestDeps(), tests, benchmarks, examples)
_xtest.TestMain(m)
}
在testing.MainStart中执行testing框架的选项注册逻辑,如-test.run、-test.timeout等等,我们可以在_xtest这个导入别名对应package中定义好flags,可以在package级别定义,也可以在func init()中定义,也可以在func TestMain()中定义,只要保证,执行flag.Parse()的时候是在TestMain或者更之后的单元测试函数中就可以。
这个时候,所有的package的选项都正常注册了,包括testing package的,在TestMain中执行flag.Parse()就不会再出现“flag … provided but not defined"的奇葩情况。
区分testing flags以及自定义flags
另外,关于自定义flag与testing package定义的重名的问题,其实go test是有考虑到的,用参数–args分开就可以了,前面的是给testing解析的,后面是给自定义的解析的,testing自己的flag名带“test.”前缀,其实是可以省略掉的。
再看go test代码生成
下面是问题的回归,及定位过程中的源码分析!
_testmain.go的生成,是通过go模板来生成的,模板路径详见:src/cmd/go/internal/load/test.go,搜索变量’testmainTmpl’:
// Code generated by 'go test'. DO NOT EDIT.
package main
import (
"os"
{{if .TestMain}}
"reflect"
{{end}}
"testing"
"testing/internal/testdeps"
{{if .ImportTest}}
{{if .NeedTest}}_test{{else}}_{{end}} {{.Package.ImportPath | printf "%q"}}
{{end}}
{{if .ImportXtest}}
{{if .NeedXtest}}_xtest{{else}}_{{end}} {{.Package.ImportPath | printf "%s_test" | printf "%q"}}
{{end}}
...
)
var tests = []testing.InternalTest{
{{range .Tests}}
{"{{.Name}}", {{.Package}}.{{.Name}}},
{{end}}
}
var benchmarks = []testing.InternalBenchmark{
{{range .Benchmarks}}
{"{{.Name}}", {{.Package}}.{{.Name}}},
{{end}}
}
var examples = []testing.InternalExample{
{{range .Examples}}
{"{{.Name}}", {{.Package}}.{{.Name}}, {{.Output | printf "%q"}}, {{.Unordered}}},
{{end}}
}
func init() {
testdeps.ImportPath = {{.ImportPath | printf "%q"}}
}
...
func main() {
...
m := testing.MainStart(testdeps.TestDeps{}, tests, benchmarks, examples)
{{with .TestMain}}
{{.Package}}.{{.Name}}(m)
os.Exit(int(reflect.ValueOf(m).Elem().FieldByName("exitCode").Int()))
{{else}}
os.Exit(m.Run())
{{end}}
}
结合前面给出的测试用例helloworld.go、helloworld_test.go,以及go test生成的_testmain.go,只要对go模板稍有认识,就很容易建立起模板和代码生成的联系,是很容易理解的。
总结
go1.13 testing package初始化flags顺序发生改变,引起了一些go test时"flag … provided but not defined"的错误,暴露了我们一些开发者对go test不熟悉、对go flags官方推荐用法不熟悉。本文解释了go test的大致处理逻辑、问题产生原因以及规避该问题的建议。