“The realization came over me with full force that a good part of the remainder of my life was going to be spent in finding errors in my own programs.” - Maurice Wilkes

为了管理软件的复杂度,有两种特别有效的手段:代码评审和自动化测试。

Go 中的测试靠一个命令 go test 和若干约定来完成,这种轻量的机制使测试高效,也可以扩展到 benchmark 和文档中示例代码的编写。

在 Go 中编写测试代码和编写普通代码无异。

go testing 子命令

包目录下后缀是 _test.go 的文件是测试代码,这些文件在执行 go test 命令时被编译,执行 go build 时不会被编译。

_test.go 中的 3 种函数会被特殊处理:

  1. 函数名以 Test 开头的函数用来检验程序逻辑的正确性,go test 会调用测试函数并返回测试结果(PASS, FAIL)。
  2. 函数名以 Benchmark 开头的函数用来衡量某些操作的性能,go test 会汇报操作的平均执行时间。
  3. 函数名以 Example 开头的函数用于提供文档。

go test 会扫描 *_test.go 文件中的 3 种特殊函数,生成一个临时的 main 包用于调用它们,编译运行后输出结果,最后清空临时文件。

测试函数

每个测试文件必须引入 testing 包。

测试函数的签名:func TestName(t *testing) { //... }

  • 函数名必须以 Test 开头,Name 部分首字母必须大写。
  • t 提供了报告测试错误和一些额外信息的方法。

没有传入包的路径参数时 go test 命令会在当前目录执行。
-v 可以输出包中每个测试函数的名称和执行时间。
-run 接受正则表达式作为参数,可以指定 go test 去运行特定的测试函数。

func TestFrenchPalindrome(t *testing.T) {}
func TestCanalPalindrome(t *testing.T) {}
// go test -v -run="French|Canal"

https://godoc.org/regexp/syntax

表驱动的测试方式在 Go 中很常见:

func TestPalindrome(t *testing.T) {
	var tests = []struct {
		input string
		want  bool
	}{
		{"", true},
		{"a", true},
		{"aa", true},
		{"ab", false},
		{"kayak", true},
		{"detartrated", true},
		{"A man, a plan, a canal: Panama", true},
		{"Evil I did dwell; lewd did I live.", true},
		{"Able was I ere I saw Elba", true},
		{"été", true},
		{"Et se resservir, ivresse reste.", true},
		{"palindrome", false},
		{"desserts", false},
	}
	for _, test := range tests {
		if got := IsPalindrome(test.input); got != test.want {
			t.Errorf("IsPalindrome(%q) = %v", test.input, got)
		}
	}
}

表驱动的测试方法可以很方便地添加测试用例。

若需要在一个测试用例失败后停止测试,可以调用 t.Fatalt.Fatalf,但是必须在测试函数所在的同一个协程下面发起调用。

测试失败的信息通常是 "f(x) = y, want z" 的形式。

  • 尽可能用 Go 本身的语法表示 f(x) 的内容。
  • 表驱动测试中展示 x 的值很重要,因为一个断言会用不同的值执行多遍。
  • 减少冗余信息,例如测试的方法的返回值是布尔型,则可以省略 want z;若 x, yz 很长,则可以用简要的总结来替代。

随机测试

表测试适合用于判断函数是否能通过精心设计的输入的测试。

随机测试可以更大范围地测试不同的输入。

确定随机输入对应的输出的方法:

  1. 用另一种简单、清晰但是低效的算法实现被测试函数的逻辑,检查两套逻辑的测试结果是否相同。

  2. 按一定的模式创建随机输入使得输出是可以预测的。

    func randomPalindrome(rng *rand.Rand) string {
        n := rng.Intn(25)               // random length up to 24
        runes := make([]rune, n)
        for i := 0; i < (n+1)/2; i++ {
            r := rune(rng.Intn(0x1000)) // random rune up to '\u0999'
            runes[i] = r
            runes[n-1-i] = r
        }
        return string(runes)
    }
    func TestRandomPalindrome(t *testing.T) {
        seed := time.Now().UTC().UnixNano()
        t.Logf("Random seed: %d", seed)
        rng := rand.New(rand.NewSource(seed))
        for i := 0; i < 1000; i++ {
            p := randomPalindrome(rng)
            if !IsPalindrome(p) {
                t.Errorf("IsPalindrome(%q) = false", p)
            }
        }
    }
    
    • 用当前时间的时间戳作为随机数生成器的种子,每次运行测试时都可以测试到不同的输入。
    • 日志记录下种子信息后可以方便地重现错误。

命令测试

go test 通常用于库的测试,也可以用来测试一个命令。

包名是 main 的包会生成一个可执行文件,但这样的包也可以作为一个库来导入。

// echo.go
package main
var (
	n = flag.Bool("n", false, "omit trailing newline")
	s = flag.String("s", " ", "separator")
)
var out io.Writer = os.Stdout       // out will be modified during testing
func main() {                       // main gets ignored during testing 
	flag.Parse()
	if err := echo(!*n, *s, flag.Args()); err != nil {
		fmt.Fprintf(os.Stderr, "echo: %v\n", err)
		os.Exit(1)
	}
}
func echo(newline bool, sep string, args []string) error {
	fmt.Fprintf(out, strings.Join(args, sep))
	if newline {
		fmt.Fprintln(out)
	}
	return nil
}
// echo_test.go
package main
func TestEcho(t *testing.T) {       // same naming convention applies here
	var tests = []struct {
		newline bool
		sep     string
		args    []string
		want    string
	}{
		{true, ",", []string{"a", "b", "c"}, "a,b,c\n"},
		{false, ":", []string{"1", "2", "3"}, "1:2:3"},
	}
	for _, test := range tests {
		descr := fmt.Sprintf("echo(%v, %q, %q)", test.newline, test.sep, test.args)
		out = new(bytes.Buffer)     // captured output
		if err := echo(test.newline, test.sep, test.args); err != nil {
			t.Errorf("%s failed: %v", descr, err)
			continue
		}
		got := out.(*bytes.Buffer).String()
		if got != test.want {
			t.Errorf("%s = %q, want %q", descr, got, test.want)
		}
	}
}

测试代码同样在 main 包下,测试过程中,main 包作为一个库把 TestEcho 函数暴露给 Go 的测试驱动,main 函数则被忽略

被测试的代码不应该调用 log.Fatal 或者 os.Exit,否则会打断进行中的测试(调用这些方法应当是 main 函数的特权)。这样一来,若测试过程中被测试的函数 panic 了,测试驱动也可以将其 recover 并继续测试,引起 panic 的用例会被认定为测试失败。

预期的(设计好的)错误类型应当作为非空的 error 返回。

TestEcho 中全局变量 out 的值由 os.Stdout 被替换成同样实现了 io.Writer 接口的 *bytes.Buffer 类型,方便后面的判断逻辑。

  • 用这种技巧,也可以将实际生产中的代码替换成易于测试的“伪实现”。伪实现的好处是更易配置,可预测性、可靠性更强,更易观察,也避免了类似不小心更新了生产数据等触发生产中业务逻辑的情况。
  • 一个测试函数用伪实现测试完毕后,需要将原来的实现恢复回去,避免污染到其它还未执行的、要测试实际实现的测试函数的逻辑;可以用 defer 方便地恢复赋值。
  • This pattern can be used to temporarily save and restore all kinds of global variables, including command-line flags, debugging options, and performance parameters; to install and remove hooks that cause the production code to call some test code when something interesting happens; and to coax the production code into rare but important states, such as timeouts, errors, and even specific interleavings of concurrent activities.

go test 通常不会并发执行多个测试,所以以这样的方式更新全局变量是安全的。

白盒测试

黑盒测试只关心暴露出来的 API,不关心内部实现。

白盒测试可以访问到不公开对外暴露的函数和数据结构。

两种测试方法互补:

  • 黑盒测试通常更健壮,不太需要随着库代码的迭代而更新;黑盒测试可以反映出 API 设计上的缺陷。
  • 白盒测试可以对代码中晦涩易出错的部分提供更细致的测试。

测试用的外部包

net/url 提供 URL 解析的功能,net/http 提供了 web 服务 和 HTTP 客户端的库;位于上层的 net/http 依赖于位于底层的 net/url;位于底层的 net/url 包中的某个测试方法需要引用上层的 net/http 包,此时若将该测试函数定义在底层的 net/url 包中,会出现下层引用上层,形成循环引用。

Go 不允许循环引用。

解决办法是将会造成循环引用的测试方法定义在包的声明是 package url_test 的测试代码文件中,放到 net/url 包所在的目录里,go test 在运行时会为包的声明语句中包含 _test 后缀的测试代码文件单独编译出包并执行里面的测试。

  • 此包的引用路径是 net/url_test,但是它不能以 net/url_test 或其它任何名称被引用。
  • 从层级上来说,这个测试用的外部包在 net/httpnet/url 的上层,破除的循环引用的链条。
  • 包含 package url_test 的代码文件中必须引用 net/url 后才可以使用到其中的功能,和普通的引用包的方式无异。
  • 这种单独编译出来的测试用的包称为 external test package。

外部引用包解决了循环引用的问题,使得测试代码可以自由地引用其它的包,这一点对于需要测试若干组件之间的交互的集成测试来说尤其重要。

go list 可以将一个包中的生产代码文件、包内测试代码文件和外部测试文件分类。

ls /usr/local/go/src/net/url
# example_test.go   url.go  url_test.go
go list -f={{.GoFiles}} net/url
# [url.go]
go list -f={{.TestGoFiles}} net/url
# [url_test.go]
go list -f={{.XTestGoFiles}} net/url
# [example_test.go]

head -n 13 example_test.go | tail -n 9
#package url_test

#import (
#        "encoding/json"
#        "fmt"
#        "log"
#        "net/url"
#        "strings"
#)
  • GoFilesgo build 命令会用到的生产代码文件。
  • TestGoFiles 是属于包本身的测试代码文件,只在 go test 时用到。
  • XTestGoFiles 是组成外部测试包的文件,只在 go test 时用到。

在白盒测试中,有时一个外部测试包需要在测试过程中访问到包的内部状态,对于这种情况,可以在包内测试代码文件中加入一些声明语句,将必要的内部状态暴露给外部测试包。如果一个测试代码文件存在的目的只是为了给外部测试包暴露内部状态而本身不包含任何测试代码,这种文件通常被命名为 export_test.go

  • fmt 包的 scanf 方法需要用到 unicode.IsSpace,但 fmt 为了避免引入 unicode 包中大量的表数据,选择不去引入 unicode 包而是自己实现一个简易版本的 isSpace。为了保证 isSpaceunicode.IsSpace 行为一致,fmt 需要包含相应的测试。测试代码在一个外部测试包(fmt_test.go)中,fmt 通过 export_test.go 文件将内部的 isSpace 方法暴露出来。
cat /usr/local/go/src/fmt/export_test.go
# package fmt
# var IsSpace = isSpace
# var Parsenum = parsenum

References