golang cmd test framework

缘由

其实测试 go 本身并不是一件简单的事情,比如我们修改了一部分go源码,如何保证这个修改没有问题呢,除了简单的单元测试之外,最好的方法可能就是执行一下 go build, 看看是不是能得到自己想要的结果。

最早的 cmd/go tests 是一个主要由 shell 脚本完成的一些函数组成的库。这些测试写起来非常方便,但是运行起来却非常困难:你不能用 -run 单独的运行某一些tests,它们不能运行在 Windows 上。并且这些测试跑起来非常慢,等等吧。

后来, 随着 CL10464 的合并,一种新的 go 测试框架被引入,之前的 shell 测试脚本被逐步翻译成单独的 go test,这使得这些测试可以被选择性的执行,在 Windows 上执行,并行的执行这些测试,对于golang来说,这真的是一个不小的进步。

不过这些测试写起来还是有点难受,让我们来看一下,下面是最初在 test.bash 里面的一个测试:

	TEST 'file:line in error messages'
	# Test that error messages have file:line information at beginning of
	# the line. Also test issue 4917: that the error is on stderr.
	d=$(TMPDIR=/var/tmp mktemp -d -t testgoXXX)
	fn=$d/err.go
	echo "package main" > $fn
	echo 'import "bar"' >> $fn
	./testgo run $fn 2>$d/err.out || true
	if ! grep -q "^$fn:" $d/err.out; then
			echo "missing file:line in error message"
			cat $d/err.out
			ok=false
	fi
	rm -r $d

最终的 go 版本的测试是这样的:

func TestFileLineInErrorMessages(t *testing.T) {
	tg := testgo(t)
	defer tg.cleanup()
	tg.parallel()
	tg.tempFile("err.go", `package main; import "bar"`)
	path := tg.path("err.go")
	tg.runFail("run", path)
	shortPath := path
	if rel, err := filepath.Rel(tg.pwd(), path); err == nil && len(rel) < len(path) {
			shortPath = rel
	}
	tg.grepStderr("^"+regexp.QuoteMeta(shortPath)+":", "missing file:line in error message")
}

我们可以看到好很多了,但是仍然有一点 difficult to skim.

new test framework

聪明的 golang team 又想出了一种新的方法,和以前写一个个小的测试脚本非常类似,但是编写语言不再是shell,是一种 built-for-purpose 和shell非常像的语言。在新的测试框架下,上面的测试将会是一个独立的文件:

    testdata/script/fileline.txt:
    
            # look for short, relative file:line in error message
            ! go run ../../gopath/x/y/z/err.go
            stderr ^..[\\/]x[\\/]y[\\/]z[\\/]err.go:
    
            -- ../x/y/z/err.go --
            package main; import "bar"

当我们执行 go test cmd/go -run=Script/^fileline$ 执行上面的测试时候,我们看看这个测试框架到底怎么运行的呢,首先 go test 会执行 script_test.go 这个文件,这个文件驱动了整个测试框架的运行 (script engine),所有定义在测试脚本中的命令都会在这个文件中实现。比如脚本中 rm 的实现:

// rm removes files or directories.
func (ts *testScript) cmdRm(neg bool, args []string) {
	if neg {
		ts.fatalf("unsupported: ! rm")
	}
	if len(args) < 1 {
		ts.fatalf("usage: rm file...")
	}
	for _, arg := range args {
		file := ts.mkabs(arg)
		removeAll(file)              // does chmod and then attempts rm
		ts.check(os.RemoveAll(file)) // report error
	}
}

txtar

每一个测试脚本都是文本格式的,下面是一个经典的 helloworld 测试脚本:

	# hello world
	go run hello.go
	stderr 'hello world'
	! stdout .

	-- hello.go --
	package main
	func main() { println("hello world") }

上面的这个测试脚本其实就是 txtar 格式的,这是为了集成新的测试框架而专门引入的一种新的文件格式,可以在 CL 123359中看到详细的说明。每一个这样的脚本会被当做是 subtest, 所以它们仍然可以被go独立执行。

这些脚本默认情况下会被很好的隔离开,所有的脚本都被看成是可以并行的 subtest, 这些脚本没有 cmd/go 源码目录的访问权限,甚至没有cmd/go/testdata目录的访问权限,所以它们不能修改这些地方的文件,不能在这些地方创建临时目录。

每一个测试脚本在运行时都会创建一个属于自己的全新的临时目录,你可以使用 $WORK 这个环境变量来使用这个目录,当然还定义了很多的默认环境变量以供编写测试的时候使用:

	GOARCH=<target GOARCH>
	GOCACHE=<actual GOCACHE being used outside the test>
	GOOS=<target GOOS>
	GOPATH=$WORK/gopath
	GOPROXY=<local module proxy serving from cmd/go/testdata/mod>
	GOROOT=<actual GOROOT>
	HOME=/no-home
	PATH=<actual PATH>
	TMPDIR=$WORK/tmp
	devnull=<value of os.DevNull>
	goversion=<current Go version; for example, 1.12>

脚本中以 # 开头的当然是注释了,用来说明这个测试的作用。! 前缀用来表明这个命令必须失败,如果命令执行成功了,那么这个测试将会失败,当然了也不是每个命令都支持这个前缀的。

txtar中定义的所有命令如下:


- cd dir
  进入某个目录

- cmp file1 file2
  比较两个文件

- cmpenv file1 file2
  和cmp很类似,但是它会替换文件中的环境变量

- cp src... dst
  复制文件到目标

- env [key=value...]
  定义环境变量,如果没有参数,将列出所有环境变量,这在debug的时候非常有用

- [!] exec program [args...] [&]
  执行给定的命令,需要说明的是exec不会结束这个脚本
  如果后缀有&符号,那么这个命令将被放在后台执行,一般搭配wait来使用。

- [!] exists [-readonly] file...
  判断某个文件是否存在,如果添加了-readonly那么这个文件应该是不可写的

- [!] go args... [&]
  执行go命令

- [!] grep [-count=N] pattern file
  文件内容需符合grep条件,-count=N参数表明必须有N条匹配的数据在文件中。

- mkdir path...
  创建文件夹

- rm file...
  删除文件

- skip [message]
  跳过测试

- [!] stale path...
  给定路径的包名被go认定为 "stale"

- [!] stderr [-count=N] pattern
  从标准错误中grep相应的数据,和grep类似

- [!] stdout [-count=N] pattern
  从标准输出中grep相应的数据,和grep类似

- stop [message]
  停止执行test,会被标记为test pass

- symlink file -> target
  创建软连接文件

- wait
  等待所有的后台执行完成