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的大致处理逻辑、问题产生原因以及规避该问题的建议。