最近一直在写 Go 语言测试相关的文章,从《在 Go 中如何编写测试代码》开始,已经更新了七篇文章。本来打算测试系列文章就此告一段落,近期不再写相关内容了。但是重读一遍这个系列的文章,发现还是有一些遗漏的知识点,和之前文章中提到过但没有深入讲解的内容。本文就作为这个系列文章的一个补充,讲解下我认为在 Go 测试中还有哪些值得一写的内容。
准备
本文所有测试用例都是基于 Abs
函数编写的,Abs
函数定义如下:
1 | func Abs(x int) int { |
TestMain
有些时候,我们可能需要在测试前执行一些准备工作,测试后执行一些清理工作。比如测试前启动一个测试用的 HTTP Server,测试后关闭这个 Server。TestMain
函数就是用来干这个的,它相当于测试中的 main
函数。
TestMain
函数参数为 *testing.M
类型,测试开始时会最先被执行,在 TestMain
函数中可以调用 (*testing.M).Run()
,这会执行全部的测试用例。利用这个特性,我们可以在调用 (*testing.M).Run()
之前执行测试准备工作,在调用 (*testing.M).Run()
之后执行清理工作。
为 Abs
函数编写单元测试代码如下:
1 | func TestAbs(t *testing.T) { |
setup()
可以用来执行准备工作,teardown()
用来执行清理工作,m.Run()
执行全部测试用例后会返回程序退出码,可以在程序退出时传递给 os.Exit()
函数。
使用 go test
来执行测试函数:
1 | $ go test -v |
执行结果符合预期。
Setup/Teardown
TestMain
函数是全局粒度的,有些时候,我们想要单独为某一个测试用例实现准备和清理函数,可以这样做:
1 | func TestAbs(t *testing.T) { |
定义 setupTest
函数为某个测试用例提供准备工作,它接收参数为 testing.TB
接口,testing 框架中的 *testing.<T|B|F>
都实现了这个接口,所以 setupTest
函数在单元测试、基准测试、模糊测试中都可以使用。setupTest
函数返回的函数可以用来执行清理工作。
使用 go test
来执行测试函数:
1 | $ go test -v |
表格测试
如果我们想为一个函数编写多个测试用例,则可以使用表格测试(table-driven tests
)。
表格测试将所有的测试用例保存在结构体切片中,然后在 for
循环中依次执行每个测试用例,实现代码如下:
1 | func TestAbsWithTable(t *testing.T) { |
使用 go test
来执行测试函数:
1 | $ go test -v |
根据执行结果可以发现,每个测试用例是顺序执行的。但是存在一个问题,虽然每个测试用例的准备函数 setupTest(t)
是在当前轮次循环中进行调用的,可清理函数 teardownTest(t)
却是在 for
循环执行完成后,才会被依次执行。这是 Go 语言在 for
循环中使用 defer
语句天然存在的问题,如果你不知道如何解决,可以参考我的另一篇文章《在 Go 中如何实现类似 Python 中的 with 上下文管理器》。
现在我们尝试将第一个测试用例故意改错,将 want
值修改为 2
:
1 | { |
再次使用 go test
来执行测试函数:
1 | $ go test -v -run="TestAbsWithTable" |
可以发现,第一个测试用例执行报错了,并且测试直接终止,没有继续执行第二个测试用例。
这个表现与我们直接在多个函数中编写的测试用例有所不同,如果我们像下面这样定义两个测试用例:
1 | func TestAbs1(t *testing.T) { |
那么当使用 go test
执行这两个测试用例时,TestAbs1
同样会失败退出,但这并不会影响 TestAbs2
测试用例的执行。
要解决这个问题,就该 Subtests
登场了。
Subtests
Subtests 被译为子测试,子测试可以解决我们在表格测试中遇到的所有问题。
Subtests 用法很简单,仅需要将我们原来在表格测试时 for
循环中执行的代码,迁移到 (*testing.T).Run()
函数中来执行即可,实现如下:
1 | func TestAbsWithTableAndSubtests(t *testing.T) { |
我们将原来的这段代码:
1 | teardownTest := setupTest(t) |
迁移到了 t.Run()
中:
1 | t.Run(tt.name, func(t *testing.T) { |
t.Run()
第一个参数用来记录测试用例名称,第二个参数是一个匿名的函数,参数为 *testing.T
,可以将表格测试中 for
内的代码全部放在这个匿名函数中来执行。
使用 go test
来执行测试函数:
1 | $ go test -v -run="TestAbsWithTableAndSubtests" |
可以发现,使用 Subtests 后,即使第一个测试用例执行退出了,也不会影响第二个测试用例的执行。并且,这也避免了在 for
循环中直接使用 defer
语句,teardownTest(t)
函数的执行时机也正常了。
此外,我们还可以发现,Subtests 是有层级关系的,并且每一个测试用例成功和失败都会被单独标记:
1 | --- FAIL: TestAbsWithTableAndSubtests (0.00s) |
根据这几行日志,我们能够发现 TestAbsWithTableAndSubtests
测试执行失败了,它包含了两个子测试,其中 TestAbsWithTableAndSubtests/positive
子测试执行失败,而 TestAbsWithTableAndSubtests/negative
子测试执行通过。子测试的名称是测试函数名 + /
+ 传递给 t.Run()
的第一个参数 tt.name
。
我们可以单独指定需要执行的子测试,这也是普通的表格测试无法做到的。
使用 go test
执行子测试:
1 | go test -v -run="TestAbsWithTableAndSubtests/negative" |
不过,Subtests 也存在缺点,就是不支持并发执行。
如果想让其支持并行,可以使用 t.Parallel()
将其标记为可并行执行:
1 | func TestAbs(t *testing.T) { |
Subtests 的常见用法我们就介绍完了。
根据上面的测试结果,我们要牢记,表格测试一定要与子测试一起配合使用。
自动生成测试代码
通过前文的示例讲解,我们不难发现,其实表格测试是有套路的,表格测试基本框架如下:
1 | func TestAbs(t *testing.T) { |
既然基本测试框架不会变化,那么我们就可以使用程序来自动生成单元测试模板代码。
gotests
就提供了这样的功能,可以使用如下命令进行安装:
1 | $ go get -u github.com/cweill/gotests/... |
使用如下命令为所有代码生成测试:
1 | $ gotests -all -w . |
-all
表示为所有代码都生成测试。
-w
表示将输出写入指定文件,而不是标准输出。后面的 .
参数代表当前目录,gotests
会在当前目录下创建 xxx_test.go
文件并写入生成的测试代码模板。
如果只为 Abs
函数生成测试用例,可以使用 -only
参数:
1 | $ gotests -only Abs -w . |
输出 Generated TestAbs
表示测试代码模板已经生成。
如下输出则表示没有生成测试,可能是测试函数已经存在。
1 | No tests generated for . |
-only
标志接收正则参数,可以指定为 Abs
、Add
两个函数生成测试:
1 | $ gotests -only "Abs|Add" -w . |
-excl
标志与 -only
标志作用相反,为指定的函数 Abs
以外的其他函数生成测试:
1 | $ gotests -excl Abs -w . |
gotests
更多功能可以使用 gotests --help
进行查看。
何时编写测试
我花了好几篇文章来讲解如何编写测试代码,但还没讲解过应该在何时编写测试代码,现在就来简单聊聊这个话题。
根据开发周期来看,编写测试的时机有三个:
编写代码之前,先编写测试代码,即测试驱动开发 —— TDD。
编写代码过程中,每实现一个小功能(函数、方法等),就为这个小功能编写测试代码,相当于同步进行。
整个项目前期先不写测试,等项目上线稳定后,再统一为项目编写测试代码。
在这里,我最推荐第二种做法。
在我了解的国内开发团队,很少有使用 TDD 模式来编写测试代码的(也可能是我见识比较少),这跟市场环境有关。不过 TDD 开发模式在很多外企非常流行,比如 Thoughtworks 就在采用 TDD 开发模式,所以 Thoughtworks 也培养了一批大师级的 TDD 信奉者。
至于第三种做法,根据我的经验,基本上是项目周期非常短,着急上线,但最后的结果大概率是没有动力再为项目编写测试代码的。
何时执行测试
我们再来聊聊何时执行测试代码。
首先,我们每写一个单元测试,都要立即执行,验证测试程序和被测程序的正确性,这个过程中可能要多次修改和执行单元测试。
其次,在每个 feature 完成后,要执行当前项目的全部测试代码,以此来验证开发当前的功能是否对其他功能组件产生影响,这也被称作回归测试。
接着,代码会被 push 到代码仓库,此时应该在 CI 环境完整的执行一次全部测试代码,以此来保证代码被合并到主分支前是没有问题的。不过,这个过程中,可能有些测试没必要在 CI 环境执行,那么可以使用 (*testing.T).Skip
跳过这些单元测试。
最终被合并到主分支的代码,一定是测试全部通过的。
总结
本文中我们介绍了 TestMain
的用法,以及利用 TestMain
来实现全局的 setup/teardown
函数。接着又教大家如何实现单个测试用例级别的 setup/teardown
函数。
我们还学习了表格测试和子测试的用法,搞清楚了为什么要用子测试以及它能解决哪些问题。我们应该牢记,表格测试一定要与子测试一起配合使用。
最后,我又和大家聊了编写单元测试的时机以及执行单元测试的时机。
至此,关于在 Go 中如何编写测试相关的文章就告一段落了。
本文完整代码示例我放在了 GitHub 上,欢迎点击查看。
希望此文能对你有所帮助。
P.S.
本文完结后,我写的这一系列关于测试的文章,应该可以覆盖到我们平时工作和开发中超过 90% 的测试场景。虽然可能还会有一些遗漏的内容没有讲解,但既然被遗漏,说明不太常见,所以这个系列的文章暂时不会再继续深究下去,很长一段时间内我应该不会再写这个主题的文章了。
如果你在编写测试代码方面有一些心得想和我讨论分享,欢迎你联系我。
联系我
- 微信:jianghushinian
- 邮箱:jianghushinian007@outlook.com
- 博客地址:https://jianghushinian.cn
参考
- testing 文档:https://pkg.go.dev/testing@go1.20.1
- Using Setup and Teardown in Golang’s Tests:https://www.sobyte.net/post/2022-07/go-setup-and-teardown/
- gotests 源码仓库:https://github.com/cweill/gotests