1. 官方文档的“自相矛盾”
下面是关于testing包的一段官方文档(基于 Go 1.14)摘录:
要编写一个新的测试集(test suite),创建一个包含TestXxx函数的以_test.go为文件名结尾的文件。将这个测试文件放在与被测试包相同的包下面。编译被测试包时,该文件将被排除在外;执行go test时,该文件将被包含在内。
同样是官方文档,在介绍go test命令行工具时,文档如是说:
那些包名中带有_test后缀的测试文件将被编译成一个独立的包,这个包之后会被链接到主测试二进制文件中并运行。
对比这两段官方文档,我们发现了一处“自相矛盾”的地方:testing包文档告诉我们将测试代码放入与被测试包同名的包中;而go test命令行帮助文档则提到会将包名中带有_test后缀的测试文件编译成一个独立的包。
我们用一个例子来直观说明一下这个“矛盾”:如果我们要测试的包为foo,testing包的帮助文档告诉我们把对 foo 包的测试代码放在包名为foo的测试文件中;而go test命令行帮助文档则告诉我们把 foo 包的测试代码放在包名为foo_test的测试文件中。
我们将测试代码放在与被测包名相同的包下面的测试方法称为 “包内测试”,我们可以通过下面命令查看哪些测试源文件使用了“包内测试”:
$go list -f={{.TestGoFiles}} .
我们将另外一种将测试代码放在“被测包包名_test”的包下面的测试方法称为 “包外测试”。同样,我们也可以通过下面命令查看哪些测试源文件使用了“包外测试”:
$go list -f={{.XTestGoFiles}} .
2. 包内测试 vs. 包外测试
1) Go 标准库中包内测试和包外测试的使用情况
Go 标准库是 Go 代码风格和惯用法一贯的风向标。我们先来看看标准库中“包内测试”和“包外测试”各自的比重:
在$GOROOT/src目录下(Go 1.14 版本),执行下面命令组合:
// 统计标准库中采用包内测试的测试文件数量$find . -name "*_test.go" |xargs grep package |grep ':package'|grep -v "_test$"|wc -l691// 统计标准库中采用包外测试的测试文件数量$find . -name "*_test.go" |xargs grep package |grep ':package'|grep "_test$"|wc -l448
这并非是一个十分精确的统计,但一定程度上却能说明:包内测试和包外测试似乎各有各的优势。我们再以net/http这个被广泛使用的明星级别的包为例,看看包内测试和包外测试在该包测试中的应用:
进入$GOROOT/src/net/http目录下,分别执行下面命令:$go list -f={{.XTestGoFiles}}[alpn_test.go client_test.go clientserver_test.go example_filesystem_test.go example_handle_test.go example_test.go fs_test.go main_test.go request_test.go serve_test.go sniff_test.go transport_test.go]$go list -f={{.TestGoFiles}}[cookie_test.go export_test.go filetransport_test.go header_test.go http_test.go proxy_test.go range_test.go readrequest_test.go requestwrite_test.go response_test.go responsewrite_test.go server_test.go transfer_test.go transport_internal_test.go]
2) 包内测试的优势与不足
由于 Go 构建工具链在编译包时会自动根据文件名是否具有_test.go后缀将包源文件和包的测试源文件分开,测试代码不会进入包正常构建的范畴,因此测试代码使用与被测包名相同的包内测试方法是一个很自然的选择。
包内测试可以更为直接地构造测试数据和实施测试逻辑,并且可以很容易达到较高的 测试覆盖率 。因此对于追求高测试覆盖率的项目而言,包内测试是不二之选。
实施包内测试也经常会遇到如下的问题。
- 测试代码自身需要经常性的维护
- 硬伤-“包循环引用”

如果 Go 标准库对strings包的测试采用包内测试会遭遇什么呢

从上图中我们看到 Go 测试代码必须要导入引用的testing包引用了strings包,这样如果 strings 包仍然使用包内测试方法,就必然会在测试代码中出现strings包与testing包循环引用的情况。于是当我们在标准库string包目录下执行下面命令时,我们得到:
// 在$GOROOT/src/strings目录下$go list -f {{.TestGoFiles}} .[export_test.go]
我们看到标准库strings包并未采用包内测试的方法(注:export_test.go并非包内测试的测试源文件,这个后续会有详细说明)。
3) 包外测试-仅针对导出 API 的测试
因为“包循环引用”的事实存在,Go 标准库无法针对strings包实施包内测试,而解决这一问题的自然就是包外测试了:
// 在$GOROOT/src/strings目录下$go list -f {{.XTestGoFiles}} .[builder_test.go compare_test.go example_test.go reader_test.go replace_test.go search_test.go strings_test.go]
包外测试本质则是一种面向接口的黑盒测试。
包外测试将测试代码放入不同于被测试包的独立包的同时,也使得包外测试不再像包内测试那样存在“包循环引用”的硬伤,我们还以标准库中的strings包为例:

包外测试这种纯黑盒的测试还有一个功能域之外的好处,那就是可以更加聚焦地从用户视角验证被测试包导出 API 的设计的合理性和易用性。
不过包外测试的不足也是显而易见的,那就是存在 “测试盲区”。由于测试代码与被测试目标并不在同一包名下,测试代码仅有权访问被测包的导出符号,并且仅能通过导出 API 这一有限的“窗口”并结合构造特定数据来验证被测包行为。在这样的约束下,很容易出现 对被测试包的测试覆盖不足 的情况。
Go 标准库的实现者们为我们提供了一个解决包外测试这个问题的惯用法:安插“后门”。这个后门就是前面曾提到过的export_test.go文件。该文件中的代码位于被测包名下,但它既不会被包含在正式产品代码中(因为位于_test.go 文件中),又不包含任何测试代码,它仅用于将被测包的内部符号在测试阶段暴露给包外测试代码:
// $GOROOT/src/fmt/export_test.gopackage fmtvar IsSpace = isSpacevar Parsenum = parsenum
或者是定义一些辅助包外测试的代码,比如扩展被测包的方法集合:
// $GOROOT/src/strings/export_test.gopackage stringsfunc (r *Replacer) Replacer() interface{} {r.once.Do(r.buildOnce)return r.r}func (r *Replacer) PrintTrie() string {r.once.Do(r.buildOnce)gen := r.r.(*genericReplacer)return gen.printNode(&gen.root, 0)}... ...
我们可以用一幅图来直观展示export_test.go这个“后门”在不同阶段的角色(以fmt包为例):

4) 优先使用包外测试
理由如下。包外测试可以:
- 优先保证被测试包导出 API 的正确性;
- 可从用户角度验证导出 API 的有效性;
- 保持测试代码的健壮性,尽可能地降低对测试代码维护的投入;
- 不失灵活!可通过export_test.go这个“后门”来导出我们需要的内部符号,满足对包内实现逻辑窥探的需求。
