Golang Testing Parallel
本文将介绍 Golang 单元测试的串行运行和并行运行的实现细节,以及在使用 github.com/stretchr/testify/suite 库时,如何控制串行和并行。
基本流程 Basic Procedure
Test Code Scanning, Loading and Test Cases Execution
通常我们会用如下的命令运行单元测试: go test -v goexample/pkg/apiserver
,也就是我们运行 package goexample/pkg/apiserver 下的所有单元测试。下面,我们先来分析这个命令的内部流程。
首先 go test 这个命令会扫描 package 下的测试代码信息(名字以 _test.go 结尾的文件),然后会用这些代码信息重新生成一个 main 程序,或者称为 testmain 程序,执行这个程序就是在执行所有的测试用例代码。我们需要先来看一下这里生成 testmain 程序的细节,这个对于理解测试用例的串行和并行是至关重要的。
-
和扫描测试代码信息,以及生成 testmain 有关的代码在 Golang 仓库的 src/cmd/go/internal/load/test.go 文件里。这个文件最下面有一个
testmainTmpl
的变量,保存了用于生成 testmain 程序的模板。其中的main
函数部分如下:func main() { testing.RegisterCover(testing.Cover{ Mode: , Counters: coverCounters, Blocks: coverBlocks, CoveredPackages: , }) m := testing.MainStart(testdeps.TestDeps{}, tests, benchmarks, examples) .(m) os.Exit(int(reflect.ValueOf(m).Elem().FieldByName("exitCode").Int())) os.Exit(m.Run()) }
从这个代码可以看出,首先会会先执行
m := testing.MainStart()
以获得一个testing.M
对象。随后,如果你定义了TestMain
函数,就会执行该函数,否则执行m.Run()
。这里的 testing 就是标准库里的 testing 库。 -
上面代码中,传递给
testing.MainStart
函数的tests
变量是在该模板中定义的的一个[]testing.InternalTest
列表,其每个元素就对应测试 package 中的一个TestXxx(t *testing.T)
函数 (是文件级的函数,不是 testify/suite 的一个 suite 的方法,相关的代码也是在上面提到的 load/test.go 文件中,就不展开说了)。在通过testing.MainStart()
创建testing.M
对象时,这些测试用例文件都存放在了testing.M.tests
成员中。 -
接下来就是执行
testing.M.Run()
方法,在这个方法内,主要是调用 testing 库的runTests
函数(下文如无特别说明,所提到的函数都是指 testing 库中的函数)。runTests
函数可以认为是一个 package 下的所有用例的执行入口,来看下该函数:func runTests(matchString, tests []InternalTest, deadline time.Time) (ran, ok bool) { // 忽略掉控制运行次数的之类的代码 // ignore the code controlling running times { // In loops. // ctx 用于进行并发控制 // ctx is used to control parallel execution ctx := newTestContext(*parallel, newMatcher(matchString, *match, "-test.run")) ctx.deadline = deadline t := &T{ common: common{ signal: make(chan bool), barrier: make(chan bool), w: os.Stdout, }, context: ctx, } if Verbose() { t.chatty = newChattyPrinter(t.w) } tRunner(t, func(t *T) { for _, test := range tests { t.Run(test.Name, test.F) } // Run catching the signal rather than the tRunner as a separate // goroutine to avoid adding a goroutine during the sequential // phase as this pollutes the stacktrace output when aborting. go func() { <-t.signal }() }) ok = ok && !t.Failed() ran = ran || t.ran } }
上述代码的重点有 3 个:
- 这里的
t
,是整个 package 的最高级的T
对象,其他的t
都是它的儿子。我们将这个t
称为 t0。 tRunner
函数是用于执行一个用例的,它的函数定义如下:func tRunner(t *T, fn func(t *T))
,它的主要逻辑是用第一个参数t
来执行第二个参数fn
,然后在 defer 中处理 panic,以及并发控制等逻辑。func (t *T) Run(name string, f func(t *T)) bool
函数表示将第二个参数f
作为当前 receivert
的儿子用例来执行。
因此,
runTests
的逻辑可以简述为:- 先定义 t0。
-
生成 t0 对应的测试用例,就是:
func(t *T) { for _, test := range tests { t.Run(test.Name, test.F) } ... }
这里的
tests
就是上面提到的TestXxx
测试函数,即通常所说的测试用例。因为tRunner
主要逻辑就是调用这个匿名函数,因此在这个地方,就会一个接一个的执行测试文件中定义个的TestXxx
函数。 - 使用
tRunner
执行 t0 的测试用例。这里我可以先说一个结论:如果没有调用t.Parallel()
,那么t.Run()
的执行是阻塞的,会一直等到一个测试用例,即TestXxx
函数执行完成后才返回。所以,默认情况下,go test
是串行的执行测试用例的。为什么会这样等我们下面讲到t.Run()
的实现时会再说。
- 这里的
到这里,我们就了解了 go test
命令如何扫描测试代码,并且最终是如何调用我们的测试用例的。下面我们要分别看一下 tRunner
和 t.Run
这两个函数的实现细节。
tRunner
函数 func tRunner(t *T, fn func(t *T))
的基本结构如下:
func tRunner(t *T, fn func(t *T)) {
defer func() {
// handle panic
defer func() {
if didPanic {
return
}
if err != nil {
panic(err)
}
t.signal <- signal // 这个 signal 会让父用例的等待返回,见下文 t.Run 的说明。
}()
// handle subtests
if len(t.sub) > 0 {
// release 相当于释放一个锁,使得子用例可以执行。
t.context.release()
// 关闭这个 channel,表示当前用例的所有逻辑都处理完了,子用例可以开始执行。
close(t.barrier)
// 等待每个子用例执行完成。
for _, sub := range t.sub {
<-sub.signal
}
}
...
}()
fn(t)
}
整个 tRunner
函数的主要部分就两个,首先在函数内调用测试用例 fn
,然后在 defer 里处理 panic 以及等待子用例的完成。从上面的代码可以得到如下结论:父用例一定要先执行完,子用例才有机会执行。
t.Run
方法 func (t *T) Run(name string, f func(t *T))
的基本结构如下:
func (t *T) Run(name string f func(t *T)) bool {
...
// 生成一个新的 t,会集成父亲的`t.common` 部分。这里代码命名有些换乱,其实新的 t 称为 subt 更好。
// 为了描述更清晰,我们统一使用 subt 这个名字来表示子用例。
t = &T{
common: common{
barrier: make(chan bool),
signal: make(chan bool),
name: testName,
parent: &t.common,
level: t.level + 1,
creator: pc[:n],
chatty: t.chatty,
},
context: t.context,
}
...
go tRunner(t, f) // 使用 subt 来运行测试用例 f
if !<-t.signal { // 等待 subt 执行完成
runtime.Goexit()
}
return !t.failed
}
t.Run
的主要逻辑就是生成一个 subt
,然后等待 subt
执行完成。这里的等待分为两个情况:
- 默认情况下,
subt
会在执行完成后才执行t.signal <- signal
操作(见上一小节),所以此时这里是阻塞等待。上面我们也提到了,在最开始的时候,runTests
函数里 t0 对应的测试用例就是对每个用例调用t.Run
,因此默认情况下,runTests
是逐个执行用例的。 - 如果有用例调用了
t.Parallel()
,那么这里就会返回(详情见下一章)。所以如果一个 package 里每个用例都调用了t.Parallel()
,那么runTests
里的 t0 用例就会立刻返回。
接下来,我们看一下 T
这个结构体:
type T struct {
common
isParallel bool
context *testContext // For running tests and subtests.
}
你会发现,在 t.Run
函数里,还有一个需要注意的地方,就是新建的 subt
没有继承父亲 t
的 isParallel
的值,因此一个用例的所有子用例,默认是串行的。
并行流程
Golang 在 1.7 版本增加了子用例的支持,同时也支持了用例的并行执行: https://blog.golang.org/subtests. 官方的这篇 blog 有简述了并行的实现,现在我们从代码上来分析它是如何实现的。
实现并行的关键就是 t.Parallel
函数。上文我们提到,当父用例调用子用例时,即通过 t.Run
方法来执行子用例时,默认情况下会阻塞在 <-subt.signal
这里。但是当子用例调用了 t.Parallel
方法时,这里就会返回。我们可以在 t.Parallel
的代码中看到相关逻辑:
func (t *T) Parallel() {
t.isParallel = true
...
// 将自己加入父亲的 sub 列表中。
t.parent.sub = append(t.parent.sub, t)
...
// 这里直接写入成功,会使得 t.Run() 里的等待返回。
t.signal <- true
// 这里等待父用例调用 close(t.barrier),上面提到了,这个会在 tRunner 的 defer 中调用。
<-t.parent.barrier
// 并发控制,通过这个来控制用例并发数。当并发数不够时,会一直阻塞在这里。
t.context.waitParallel()
...
}
结合上文的相关信息,我们现在知道用例的并行执行是这样进行的:
- 父用例
t
的代码中会调用t.Run
来执行子用例。 t.Run
内会生成子用例subt
,然后调用tRunner
来执行subt
,并且等待subt
执行完成(<-subt.signal
)。subt
在执行的时候调用subt.Parallel()
:- 其中
subt.signal <- true
会导致上面t
的<-subt.signal
返回。 - 然后
subt
会阻塞在<-subt.parent.barrier
这里,等待父用例的函数返回(即tRunner
里调用的fn(t)
返回)。
- 其中
- 父用例
t
的函数执行完返回后,会在 defer 里close(t.barrier)
,以便子用例执行。 - 父用例等待子用例执行完。
重点强调一下,父用例调用子用例后,一定要返回,否则子用例无法执行。上述流程如下图所示:
testify/suite 与并行
github.com/stretchr/testify 这个库提供了 suite 功能,可以让我们编写测试用例更加方便。当我们使用这个库时,我们一般是这么写的:
package somename
import (
"testing"
"github.com/stretchr/testify/suite"
)
func TestXxxSuite(t1 *testing.T) {
suite.Run(t, new(xxxSuite))
}
type xxxSuite struct {
}
func (s *xxxSuite) SetupSuite() {
}
func (s *xxxSuite) TearDownSuite() {
}
func (s *xxxSuite) SetupTest() {
}
func (s *xxxSuite) TearDownTest() {
}
func (s *xxxSuite) TestCase1() {
// t1sub1
}
func (s *xxxSuite) TestCase2() {
// t1sub2
}
func TestYyySuite(t2 *testing.T) {
}
我们先来看一下 suite 库执行用例的流程。在上文中,我们已经知道,整个 package 的用例执行入口是 runTests
函数,里面会生成顶级的 t0。t0 在执行时,就会对每个顶级的函数调用 t0.Run()
方法,这个方法里会生成一个新的 subt
,用于执行我们的顶级测试函数,在这里就是 TestXxxSuite
和 TestYyySuite
,我们将这个 t0 的儿子称为 t1 和 t2,分别对应 TestXxxSuite
和 TestYyySuite
。
接下来,我们来分析一下 suite.Run
的实现,通过了解它的实现,我们可以知道 suite 库是如何组织用例的。suite.Run
的基本结构如下:
// Run 函数属于 pacagek github.com/stretchr/suite
func Run(t *testing.T, suite TestingSuite) {
// 扫描 suite 结构体,得到所有的以 Test 开头的方法,这些方法组成该 suite 下的所有用例。
// 遍历每个方法,即每个测试用例
for eachMethod {
// 判断是否需要执行 SetupSuite 方法,这个方法对于整个 suite 只会执行一次。
// 为当前正在处理的方法生成一个 testing.InternalTest 结构体,其中的成员 F 会调用测试用例对应的方法,F 的结构如下:
// F {
// call SetupTest
// call BeforeTest
// call the method
// call AfterTest
// call TearDownTest
// }
}
// 使用 t.Run 方法来执行刚才生成的所有测试用例,这里的 t 是对应 TestXxxSuite 函数
// 等所有用例都返回(这里只是返回,并不是用例执行完,下面会详细说)
// 执行 TearDownSuite
}
在上面的 Run
函数中,使用了前文提到的 t.Run
方法来执行子用例,也就是说会生成下一层的 subt
,例如 t1sub1
, t2sub2
等,如下图所示:
现在我们可以来分析一下使用 suite 库时,如何进行并行测试。首先,串行的流程如下:
runTests
创建t0
。runTests
将每个顶级函数作为子用例来执行,生成t1
,t2
等。- 顶级函数
TestXxxSuite
调用suite.Run
函数,为一个 suite 中的每个方法生成一个子用例,然后执行所有的子用例。等待所有的子用例都执行之后,该顶级函数才返回。 - 继续执行下一个顶级函数
TestYyySuite
。
总的来说,所有用例都是串行执行的,每次按照顺序执行一个 suite 内的所有用例。
当我们考虑在这个流程中启用并行时,需要考虑如下几个方面的问题:
- 一个 suite 内的每个测试用例是共享同一个 suite 的内存的,所以如果在这些方法上开启并行,那么就得考虑对共享的内存进行保护。
- 如果在一个 suite 内的测试用例上启用并行,那么需要注意,属于其父用例的函数必须执行结束后,这些子用例才会开始执行。所以,
TearDownSuite
这个方法就会变成在所有的子用例执行之前就要执行完,这个有点违背接口的含义。 - 不同 suite 之间虽然没有共享 suite 结构体的内存,但是也可能共享其他的全局变量,这就需要业务代码进行一定的调整。
综上所述,在使用 suite 库时,如果要启用并行测试,一个比较可行的策略是: 每个 suite 间启用并行,suite 内则使用串行。具体做法可以参考下面这几个步骤:
- 在
suite.SetupSuite
内调用t.Parallel()
,使得该 suite 进入并行状态。不过因为 parallel 设置是不传递给子用例的,所以该 suite 的所有子用例还是会串行执行。 - 如果被测试的代码共享了全局变量,那么需要修改被测试代码。
- 一个 suite 内的每个测试用例的配套方法(
SetupTest
,BeforeTest
,AfterTest
,TearDownTest
)可以直接访问 suite 内存而不用加锁,因为 suite 内的每个用例都是串行执行的。
示例代码如下:
package somename
import (
"testing"
"github.com/stretchr/testify/suite"
)
func TestXxxSuite(t1 *testing.T) {
suite.Run(t, new(xxxSuite))
}
type xxxSuite struct {
}
func (s *xxxSuite) SetupSuite() {
// Let the suite running in parallel with other suites.
s.T().Parallel()
}
func (s *xxxSuite) TearDownSuite() {
}
func (s *xxxSuite) SetupTest() {
// visit fields of s
}
func (s *xxxSuite) TearDownTest() {
// visit fields of s
}
func (s *xxxSuite) TestCase1() {
// t1sub1
}
func (s *xxxSuite) TestCase2() {
// t1sub2
}