40.2 包内测试与包外测试
1. Go标准库中包内测试和包外测试的使用情况
Go标准库是Go代码风格和惯用法一贯的风向标。我们先来看看标准库中包内测试和包外测试各自的比重。
在$GOROOT/src目录下(Go 1.14版本),执行下面的命令组合:
// 统计标准库中采用包内测试的测试文件数量 $find . -name "*_test.go" |xargs grep package |grep ':package'|grep -v "_test$"|wc -l 691 // 统计标准库中采用包外测试的测试文件数量 $find . -name "*_test.go" |xargs grep package |grep ':package'|grep "_test$" |wc -l 448
这并非精确的统计,但能在一定程度上说明包内测试和包外测试似乎各有优势。我们再以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]
我们看到,在针对net/http的测试代码中,对包内测试和包外测试的使用仍然不分伯仲。
2. 包内测试的优势与不足
由于Go构建工具链在编译包时会自动根据文件名是否具有_test.go后缀将包源文件和包的测试源文件分开,测试代码不会进入包正常构建的范畴,因此测试代码使用与被测包名相同的包内测试方法是一个很自然的选择。
包内测试这种方法本质上是一种白盒测试方法。由于测试代码与被测包源码在同一包名下,测试代码可以访问该包下的所有符号,无论是导出符号还是未导出符号;并且由于包的内部实现逻辑对测试代码是透明的,包内测试可以更为直接地构造测试数据和实施测试逻辑,可以很容易地达到较高的测试覆盖率。因此对于追求高测试覆盖率的项目而言,包内测试是不二之选。
但在实践中,实施包内测试也经常会遇到如下问题。
(1)测试代码自身需要经常性的维护
包内测试的白盒测试本质意味着它是一种面向实现的测试。测试代码的测试数据构造和测试逻辑通常与被测包的特定数据结构设计和函数/方法的具体实现逻辑是紧耦合的。这样一旦被测包的数据结构设计出现调整或函数/方法的实现逻辑出现变动,那么对应的测试代码也要随之同步调整,否则整个包将无法通过测试甚至测试代码本身的构建都会失败。而包的内部实现逻辑又是易变的,其优化调整是一种经常性行为,这就意味着采用包内测试的测试代码也需要经常性的维护。
(2)硬伤:包循环引用
采用包内测试可能会遇到一个绕不过去的硬伤:包循环引用。我们看图40-1。
图40-1 包内测试的包循环引用
从图40-1中我们看到,对包c进行测试的代码(c_test.go)采用了包内测试的方法,其测试代码位于包c下面,测试代码导入并引用了包d,而包d本身却导入并引用了包c,这种包循环引用是Go编译器所不允许的。
如果Go标准库对strings包的测试采用包内测试会遭遇什么呢?见图40-2。
图40-2 对标准库strings进行包内测试将遭遇“包循环引用”
从图40-2中我们看到,Go测试代码必须导入并引用的testing包引用了strings包,这样如果strings包仍然使用包内测试方法,就必然会在测试代码中出现strings包与testing包循环引用的情况。于是当我们在标准库strings包目录下执行下面命令时,我们得到:
// 在$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]
与包内测试本质是面向实现的白盒测试不同,包外测试的本质是一种面向接口的黑盒测试。这里的“接口”指的就是被测试包对外导出的API,这些API是被测包与外部交互的契约。契约一旦确定就会长期保持稳定,无论被测包的内部实现逻辑和数据结构设计如何调整与优化,一般都不会影响这些契约。这一本质让包外测试代码与被测试包充分解耦,使得针对这些导出API进行测试的包外测试代码表现出十分健壮的特性,即很少随着被测代码内部实现逻辑的调整而进行调整和维护。
包外测试将测试代码放入不同于被测试包的独立包的同时,也使得包外测试不再像包内测试那样存在“包循环引用”的硬伤。还以标准库中的strings包为例,见图40-3。
图40-3 标准库strings包采用包外测试后解决了“包循环引用”问题
从图40-3中我们看到,采用包外测试的strings包将测试代码放入strings_test包下面,strings_test包既引用了被测试包strings,又引用了testing包,这样一来原先采用包内测试的strings包与testing包的循环引用被轻易地“解”开了。
包外测试这种纯黑盒的测试还有一个功能域之外的好处,那就是可以更加聚焦地从用户视角验证被测试包导出API的设计的合理性和易用性。
不过包外测试的不足也是显而易见的,那就是存在测试盲区。由于测试代码与被测试目标并不在同一包名下,测试代码仅有权访问被测包的导出符号,并且仅能通过导出API这一有限的“窗口”并结合构造特定数据来验证被测包行为。在这样的约束下,很容易出现对被测试包的测试覆盖不足的情况。
Go标准库的实现者们提供了一个解决这个问题的惯用法:安插后门。这个后门就是前面曾提到过的export_test.go文件。该文件中的代码位于被测包名下,但它既不会被包含在正式产品代码中(因为位于_test.go文件中),又不包含任何测试代码,而仅用于将被测包的内部符号在测试阶段暴露给包外测试代码:
// $GOROOT/src/fmt/export_test.go package fmt var IsSpace = isSpace var Parsenum = parsenum
或者定义一些辅助包外测试的代码,比如扩展被测包的方法集合:
// $GOROOT/src/strings/export_test.go package strings func (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) } ...
我们可以用图40-4来直观展示export_test.go这个后门在不同阶段的角色(以fmt包为例)。
图40-4 export_test.go为包外测试充当“后门”
从图40-4中可以看到,export_test.go仅在go test阶段与被测试包(fmt)一并被构建入最终的测试二进制文件中。在这个过程中,包外测试代码(fmt_test)可以通过导入被测试包(fmt)来访问export_test.go中的导出符号(如IsSpace或对fmt包的扩展)。而export_test.go相当于在测试阶段扩展了包外测试代码的视野,让很多本来很难覆盖到的测试路径变得容易了,进而让包外测试覆盖更多被测试包中的执行路径。
4. 优先使用包外测试
经过上面的比较,我们发现包内测试与包外测试各有优劣,那么在Go测试编码实践中我们究竟该选择哪种测试方式呢?关于这个问题,目前并无标准答案。基于在实践中开发人员对编写测试代码的热情和投入时间,笔者更倾向于优先选择包外测试,理由如下。包外测试可以:
- 优先保证被测试包导出API的正确性;
- 可从用户角度验证导出API的有效性;
- 保持测试代码的健壮性,尽可能地降低对测试代码维护的投入;
- 不失灵活!可通过export_test.go这个“后门”来导出我们需要的内部符号,满足窥探包内实现逻辑的需求。
当然go test也完全支持对被测包同时运用包内测试和包外测试两种测试方法,就像标准库net/http包那样。在这种情况下,包外测试由于将测试代码放入独立的包中,它更适合编写偏向集成测试的用例,它可以任意导入外部包,并测试与外部多个组件的交互。比如:net/http包的serve_test.go中就利用httptest包构建的模拟Server来测试相关接口。而包内测试更聚焦于内部逻辑的测试,通过给函数/方法传入一些特意构造的数据的方式来验证内部逻辑的正确性,比如net/http包的response_test.go。
我们还可以通过测试代码的文件名来区分所属测试类别,比如:net/http包就使用transport_internal_test.go这个名字来明确该测试文件采用包内测试的方法,而对应的transport_test.go则是一个采用包外测试的源文件。
小结
在这一条中,我们了解了go test的执行原理,对比了包内测试和包外测试各自的优点和不足,并给出了在实际开发过程中选择测试类型的建议。
本条要点:
- go test执行测试的原理;
- 理解包内测试的优点与不足;
- 理解包外测试的优点与不足;
- 掌握通过export_test.go为包外测试添加“后门”的惯用法;
- 优先使用包外测试;
- 当运用包外测试与包内测试共存的方式时,可考虑让包外测试和包内测试聚焦于不同的测试类别。