7.3.3 单元测试实现原理
简介
在了解过testing.common后,我们进一步了解testing.T数据结构,以便了解更多单元测试执行的更多细节。
数据结构
源码包src/testing/testing.go:T
定义了其数据结构:
type T struct {
common
isParallel bool
context *testContext // For running tests and subtests.
}
其成员简单介绍如下:
- common: 即前面绍的testing.common
- isParallel: 表示当前测试是否需要并发,如果测试中执行了t.Parallel(),则此值为true
- context: 控制测试的并发调度
因为context直接决定了单元测试的调度,在介绍testing.T支持的方法前,有必要先了解一下context。
testContext
源码包src/testing/testing.go:testContext
定义了其数据结构:
type testContext struct {
match *matcher
mu sync.Mutex
// Channel used to signal tests that are ready to be run in parallel.
startParallel chan bool
// running is the number of tests currently running in parallel.
// This does not include tests that are waiting for subtests to complete.
running int
// numWaiting is the number tests waiting to be run in parallel.
numWaiting int
// maxParallel is a copy of the parallel flag.
maxParallel int
}
testContext成员简单介绍如下:
- match:匹配器,用于管理测试名称匹配、过滤等。
- mu:互斥锁,用于控制testContext成员的互斥访问;
- startParallel: 用于通知测试可以并发执行的控制管道,测试并发达到最大限制时,需要阻塞等待该管道的通知事件;
- running: 当前并发执行的测试个数;
- numWaiting:等待并发执行的测试个数,所有等待执行的测试都阻塞在startParallel管道处;
- maxParallel:最大并发数,默认为系统CPU数,可以通过参数
-parallel n
指定。
testContext实现了两个方法用于控制测试发调度。
等待并发执行:testContext.waitParallel()
如果一个测试使用t.Parallel()
启动并发,这个测试并不是立即被并发执行,需要检查当前并发执行的测试数量是否达到最大值,这个检查工作统一放在testContext.waitParallel()实现的。
testContext.waitParallel()函数的源码如下:
func (c *testContext) waitParallel() {
c.mu.Lock()
if c.running < c.maxParallel { // 如果当前运行的测试数未达到最大值,直接返回
c.running++
c.mu.Unlock()
return
}
c.numWaiting++ // 如果当前运行的测试数已达最大值,需要阻塞等待
c.mu.Unlock()
<-c.startParallel
}
函数实现比较简单,如果当前运行的测试数未达最大值,将c.running++后直接返回即可,否则将c.numWaiting++并阻塞等待其他并发测试结束。
这里有个小细节,阻塞等待后面并没有累加c.running,因为其他并发的测试结束后也不会递减c.running,所以这里阻塞返回时也不用累加,一个测试结束,随即另一个测试开始,c.running个数没有变化。
并发测试结束:testContext.release()
当并发测试结束后,会通过release()方法释放一个信号,用于启动其他等待并发测试的函数。
testContext.release()函数的源码如下:
func (c *testContext) release() {
c.mu.Lock()
if c.numWaiting == 0 { // 如果没有函数在等待,直接返回
c.running--
c.mu.Unlock()
return
}
c.numWaiting-- // 如果有函数在等待,释放一个信号
c.mu.Unlock()
c.startParallel <- true // Pick a waiting test to be run.
}
测试执行:tRunner()
函数tRunner用于执行一个测试,在不考虑并发测试、子测试场景下,其处理逻辑如下:
func tRunner(t *T, fn func(t *T)) {
defer func() {
t.duration += time.Since(t.start)
signal := true
t.report() // 测试执行结束后向父测试报告日志
t.done = true
t.signal <- signal // 向调度者发送结束信号
}()
t.start = time.Now()
fn(t)
t.finished = true
}
tRunner传一个经调度者设置过的testing.T参数和一个测试函数,执行时记录开始时间,然后将testing.T参数传入测试函数并同步等待其结束。
tRunner在defer语句中记录测试执行耗时,并上报日志,最后发送结束信号。
为了避免困惑,上述代码屏蔽了一些子测试和并发测试的细节,比如,defer语句中,如果当前测试包含子测试,则需要等所有子测试结束,如果当前测试为并发测试,则需要唤醒其他等待并发的测试。更多细节,等我们分析Parallel()和Run()时再讨论。
启动子测试:Run()
Run()函数用于启动一个子测试,这个子测试可以是用户的测试函数中主动调用Run()方法启动的,如果用户没有主动调用Run()方法,那么用户的测试函数也是被调度程序以Run()方法启动的。可以说,所有的测试都是Run()方法启动的。
按照惯例,隐去部分代码后的Run()方法如下所示:
func (t *T) Run(name string, f func(t *T)) bool {
t = &T{ // 创建一个新的testing.T用于执行子测试
common: common{
barrier: make(chan bool),
signal: make(chan bool),
name: testName,
parent: &t.common,
level: t.level + 1, // 子测试层次+1
chatty: t.chatty,
},
context: t.context, // 子测试的context与父测试相同
}
go tRunner(t, f) // 启动协程执行子测试
if !<-t.signal { // 阻塞等待子测试结束信号,子测试要么执行结束,要么以Parallel()执行。如果信号为'false',说明出现异常退出
runtime.Goexit()
}
return !t.failed // 返回子测试的执行结果
}
每启动一个子测试都会创建一个testing.T变量,该变量继承当前测试的部分属性,然后以新协程去执行,当前测试会在子测试结束后返回子测试的结果。
子测试退出条件要么是子测试执行结束,要么是子测试设置了Paraller(),否则是异常退出。
启动并发测试:Parallel()
Parallel()方法将当前测试加入到并发队列中,其实现方法如下所示:
func (t *T) Parallel() {
t.isParallel = true
t.duration += time.Since(t.start) // 启动并发测试有可能要等待,等待期间耗时需要剔除,此处相当于先记录当前耗时,并发执行开始后再累加
t.parent.sub = append(t.parent.sub, t) // 将当前测试加入到父测试的列表中,由父测试调度
t.signal <- true // Release calling test. 当前测试即将进入并发模式,标记测试结束,以便父测试不必等待并退出Run()
<-t.parent.barrier // Wait for the parent test to complete. 等待父测试发送子测试启动信号
t.context.waitParallel() // 阻塞等待并发调度
t.start = time.Now() // 开始并发执行,重新标记启动时间,这是第二段耗时
}
关于测试耗时统计,看过前面的testContext实现我们知道,启动一个并发测试时,当并发数达到最大时,新的并发测试需要等待,那么等待期间的时间消耗不能统计到测试的耗时中,所以需要先计算当前耗时,在真正被并发调度后才清空t.start以跳过等待时间。
看过前面的Run()方法实现机制后,我们知道一旦子测试以并发模式执行时,需要通知父测试,其通知机制便是向t.signal管道中写入一个信号,父测试便从Run()方法中唤醒,继续执行。
看过前面的tRunner()方法实现机制后,不难理解,父测试唤醒后继续执行,结束后进入defer流程中,在defer中将启动所有子测试并等待子测试执行结束。
完整的测试执行:tRunner()
与简单版的测试执行所不同的是,defer语句中增加了子测试、并发测试的处理逻辑,相对完整的tRunner()代码如下所示:
func tRunner(t *T, fn func(t *T)) {
t.runner = callerName(0)
defer func() {
t.duration += time.Since(t.start) // 进入defer后立即记录测试执行时间,后续流程所花费的时间不应该统计到本测试执行用时中
if len(t.sub) > 0 { // 如果存在子测试,则启动并等待其完成
t.context.release() // 减少运行计数
close(t.barrier) // 启动子测试
for _, sub := range t.sub { // 等待所有子测试结束
<-sub.signal
}
if !t.isParallel { // 如果当前测试非并发模式,则等待并发执行,类似于测试函数中执行t.Parallel()
t.context.waitParallel()
}
} else if t.isParallel { // 如果当前测试是并发模式,则释放信号以启动新的测试
t.context.release()
}
t.report() // 测试执行结束后向父测试报告日志
t.done = true
t.signal <- signal // 向父测试发送结束信号,以便结束Run()
}()
t.start = time.Now() // 记录测试开始时间
fn(t)
// code beyond here will not be executed when FailNow is invoked
t.finished = true
}
测试执行结束,进入defer后需要启动子测试,启动方法为关闭t.barrier管道,然后等待所有子测试执行结束。
需要注意的是,关闭t.barrier管道,阻塞在t.barrier管道上的协程同样会被唤醒,也是发送信号的一种方式,关于管道的更多实现细节,请参考管道实现原理相关章节。
defer中,如果检测到当前测试本身也处理并发中,那么结束后需要释放一个信号(t.context.release())来启动一个等待的测试。
如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。
绑定邮箱获取回复消息
由于您还没有绑定你的真实邮箱,如果其他用户或者作者回复了您的评论,将不能在第一时间通知您!
发布评论