Go-知识测试-单元测试
- 1. 定义
- 2. 使用
- 3. testing.common 测试基础数据
- 4. testing.TB 接口
- 5. 单元测试的原理
- 5.1 context 单元测试的调度
- 5.1.1 等待并发执行 testContext.waitParallel
- 5.1.2 并发测试结束 testContext.release
- 5.2 测试执行 tRunner
- 5.3 启动测试 Run
- 5.4 启动并发测试 Parallel
1. 定义
单元测试是指对软件中的最小可测试单元进行检查和验证,比如对一个函数的测试。
单元测试要保证测试文件以_test.go
结尾。
测试方法必须以TestXxx
开头。
测试文件可以与源码处于同一目录,也可以处于单独的目录。
2. 使用
比如
执行单个TestXxx
执行整个测试文件
使用 go test
即可触发测试
3. testing.common 测试基础数据
每个单元测试都有一个入参t *testing.T
,结构定义如下:
type T struct {
common
isEnvSet bool
context *testContext // For running tests and subtests.
}
T 组合了 common 类型
// common包含T和B之间的公共元素,以及
// 捕获常见的方法,如Errorf。
type common struct {
mu sync.RWMutex // 保卫这群田地
output []byte // 测试或基准测试生成的输出。
w io.Writer // 对于flushToParent。
ran bool // 执行了测试或基准测试(或其中一个子测试)。
failed bool // 测试或基准测试失败。
skipped bool // 已跳过测试或基准测试。
done bool // 测试已完成,所有子测试均已完成。
helperPCs map[uintptr]struct{} // 写入文件/行信息时要跳过的函数
helperNames map[string]struct{} // helperPC转换为函数名
cleanups []func() // 测试结束时要调用的可选函数
cleanupName string // 清除函数的名称。
cleanupPc []uintptr // 调用Cleanup的点处的堆栈跟踪。
finished bool // 测试功能已完成。
chatty *chattyPrinter // 如果设置了chatty标志,则为chattyPrinter的副本。
bench bool // 当前测试是否为基准测试。
hasSub int32 // 以原子形式书写。
raceErrors int // 测试过程中检测到的种族数。
runner string // 运行测试的tRunner的函数名称。
parent *common
level int // 测试或基准的嵌套深度。
creator []uintptr // 如果级别>0,则堆栈跟踪父级调用t.Run的点。
name string // 测试或基准的名称。
start time.Time // 时间测试或基准测试已启动
duration time.Duration
barrier chan bool // 为了发出平行子测验的信号,他们可以开始。
signal chan bool // 发出测试完成的信号。
sub []*T // 要并行运行的子测试的队列。
tempDirMu sync.Mutex
tempDir string
tempDirErr error
tempDirSeq int32
}
每个测试均对应一个 testing.common
,不仅记录了测试函数的基础信息(比如名字),还管理了测试的执行过程和测试结果。
testing.commong
是单元测试,性能测试和模糊测试的基础。
通过继承共同的结构,保证了各种测试的行为一致,降低使用的门槛。
4. testing.TB 接口
testing.common 实现的接口为 testing.TB,单元测试和性能测试通过该接口获取基础能力。
type TB interface {
Cleanup(func()) // 清理
Error(args ...interface{}) // 表示测试失败+记录日志
Errorf(format string, args ...interface{}) // 格式化表示测试失败+记录日志
Fail() // 表示测试失败
FailNow() // 标记测试失败+结束当前测试
Failed() bool // 查询结果
Fatal(args ...interface{}) // 标记测试失败+记录日志+结束当前测试
Fatalf(format string, args ...interface{}) // 格式化标记测试失败+记录日志+结束当前测试
Helper() // 标记测试为 Helper (避免打印当前代码行号)
Log(args ...interface{}) // 记录日志
Logf(format string, args ...interface{}) // 格式化 记录日志
Name() string // 查询测试名
Setenv(key, value string) // 设置环境变量
Skip(args ...interface{}) // 记录日志+跳过测试
SkipNow() // 跳过测试
Skipf(format string, args ...interface{}) // 格式化记录日志+跳过测试
Skipped() bool // 查询测试是否被跳过
TempDir() string // 返回一个临时目录
//阻止用户实现的私有方法
//接口,因此将来不会添加
//违反Go 1兼容性。
private()
}
实际上单元测试,性能测试和模糊测试都会使用接口。
接口定义中的 private() 方法是一个很巧妙的用法
目的是限定 testing.TB 接口全局唯一,防止用户自行实现 testing.TB接口
类似Java 的 final 关键字
因为 private 是小写的,包内可见,这样用户就无法实现 private 方法
用户也就无法实现 testing.TB 接口了
5. 单元测试的原理
单元测试的函数的入参是testing.T
类型:
type T struct {
common
isParallel bool // 是否需要并发
isEnvSet bool // 是否设置环境变量
context *testContext //用于运行测试和子测试。
}
5.1 context 单元测试的调度
context 是调度单元测试的关键:
// testContext包含所有测试通用的所有字段。这包括
// 同步原语最多运行*个并行测试。
type testContext struct {
match *matcher // 匹配器,用于管理测试名称匹配、过滤等
deadline time.Time // 结束时间,防止测试运行过长
mu sync.Mutex // 互斥锁,用于控制 testContext 成员的互斥访问
startParallel chan bool // 用于向准备并行运行的测试发出信号的通道。
running int // running是当前并行运行的测试数。这不包括等待子测验完成的测验。
numWaiting int // numWaiting是等待并行运行的测试数。
maxParallel int // maxParallel是并行标志的副本。
}
其初始化代码
func newTestContext(maxParallel int, m *matcher) *testContext {
return &testContext{
match: m,
startParallel: make(chan bool),
maxParallel: maxParallel,
running: 1, // 设置主测试
}
}
5.1.1 等待并发执行 testContext.waitParallel
如果一个测试使用t.Parallel()
启动并发,那么这个测试并不是立即被并发执行,需要检查当前
并发执行的测试数是否达到最大值,这个检查工作统一放在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()
// 获取启动的令牌信号,需要注意,chan 是没有缓冲区的
// 也就是会阻塞,直到有生产 的 chan ,才会解除阻塞
<-c.startParallel
}
阻塞等待后面没有累加 running ,因为 running 表示的运行中的个数
阻塞解除,必然表示一个测试结束,当前测试开始,运行中的个数没有变化,所以不增加
5.1.2 并发测试结束 testContext.release
当并发测试结束后,会通过 release 方法释放一个信号,用于启动其他等待并发测试的函数
func (c *testContext) release() {
// 加锁
c.mu.Lock()
// 如果没有等待启动的测试,那么直接将 running--
if c.numWaiting == 0 {
c.running--
c.mu.Unlock()
return
}
// 否则将等待的数量减1,然后生产一个启动的信号
c.numWaiting--
c.mu.Unlock()
c.startParallel <- true // Pick a waiting test to be run.
}
5.2 测试执行 tRunner
func tRunner(t *T, fn func(t *T)) {
t.runner = callerName(0) // 获取当前测试函数的名称
//当这个goroutine完成时,要么是因为fn(t)
//正常返回或由于触发测试失败
//对运行时的调用。Goexit,记录持续时间并发送
//表示测试完成的信号。
defer func() {
// 测试失败,那么将失败数+1
if t.Failed() {
atomic.AddUint32(&numFailed, 1)
}
// 如果测试惊慌失措,请在终止之前打印任何测试输出。
err := recover()
signal := true
// 读锁定
t.mu.RLock()
// 获取完成状态
finished := t.finished
// 读锁定解锁
t.mu.RUnlock()
// 如果测试未完成,但是异常信息为空
if !finished && err == nil {
// 将错误信息赋值为空错误或空异常
err = errNilPanicOrGoexit
// 如果有父测试,当前是子测试
for p := t.parent; p != nil; p = p.parent {
p.mu.RLock()
finished = p.finished
p.mu.RUnlock()
if finished {
t.Errorf("%v: subtest may have called FailNow on a parent test", err)
err = nil
signal = false
break
}
}
}
// 使用延迟调用以确保我们报告测试
// 完成,即使清除函数调用t.FailNow。请参见第41355期。
didPanic := false
defer func() {
if didPanic {
return
}
if err != nil {
panic(err)
}
//只有在没有恐慌的情况下才报告测试完成,
//否则,测试二进制文件可以在死机之前退出
//报告给用户。请参见第41479期。
t.signal <- signal
}()
doPanic := func(err interface{}) {
// 设置测试失败
t.Fail()
if r := t.runCleanup(recoverAndReturnPanic); r != nil {
t.Logf("cleanup panicked with %v", r)
}
//在终止之前将输出日志刷新到根目录。
for root := &t.common; root.parent != nil; root = root.parent {
root.mu.Lock()
// 计算时间
root.duration += time.Since(root.start)
d := root.duration
root.mu.Unlock()
root.flushToParent(root.name, "--- FAIL: %s (%s)\n", root.name, fmtDuration(d))
if r := root.parent.runCleanup(recoverAndReturnPanic); r != nil {
fmt.Fprintf(root.parent.w, "cleanup panicked with %v", r)
}
}
didPanic = true
panic(err)
}
if err != nil {
doPanic(err)
}
t.duration += time.Since(t.start)
// 如果有子测试,当前是父测试
if len(t.sub) > 0 {
// 停止测试
t.context.release()
// 释放平行的子测验。
close(t.barrier)
// 等待子测验完成。
for _, sub := range t.sub {
<-sub.signal
}
cleanupStart := time.Now()
err := t.runCleanup(recoverAndReturnPanic)
t.duration += time.Since(cleanupStart)
if err != nil {
doPanic(err)
}
// 如果不是并发的
if !t.isParallel {
// 等待开始
t.context.waitParallel()
}
} else if t.isParallel { // 如果是并发的
//仅当此测试以并行方式运行时才释放其计数 测验请参阅Run方法中的注释。
t.context.release()
}
// 测试执行结束上报日志
t.report()
t.done = true
// 如果有父测试,那么设置执行标志
if t.parent != nil && atomic.LoadInt32(&t.hasSub) == 0 {
t.setRan()
}
}()
defer func() {
if len(t.sub) == 0 {
t.runCleanup(normalPanic)
}
}()
t.start = time.Now()
t.raceErrors = -race.Errors()
fn(t)
// code beyond here will not be executed when FailNow is invoked
t.mu.Lock()
t.finished = true
t.mu.Unlock()
}
测试命令go test
首先会触发RunTests
方法
然后会根据传入参数进行启动
所以在 tRunner
中的 fn 就是 一个 for 循环,循环启动测试case
所以也就是说 tRunner
是一个公共的方法,不管是单元测试,还是性能测试,还是模糊测试,都会调用tRunner
这也是为何tRunner
方法中混杂了性能测试,子测试的逻辑。
tRunner 传递一个经调度这设置过的 testing.T 参数和一个测试函数,执行时记录开始时间,
然后将testing.T 参数传入测试函数并同步等待结束。
tRunner在defer语句中记录测试执行耗时,并上报日志,最后发送结束信号。
5.3 启动测试 Run
在调用顺序中,调用tRunner
的是Run
// 将运行f作为名为name的t的子测试。它在一个单独的goroutine中运行f
// 并且阻塞直到f返回或调用t。并行成为并行测试。
// 运行报告f是否成功(或者至少在调用t.Parallel之前没有失败)。
//
// Run可以从多个goroutine同时调用,但所有此类调用
// 必须在t的外部测试函数返回之前返回。
func (t *T) Run(name string, f func(t *T)) bool {
// 将子测试的数量+1
atomic.StoreInt32(&t.hasSub, 1)
// 获取匹配的测试name
testName, ok, _ := t.context.match.fullName(&t.common, name)
// 如果没有配置,那么直接结束
if !ok || shouldFailFast() {
return true
}
//记录此调用点的堆栈跟踪,以便如果子测试
//在单独的堆栈中运行的函数被标记为助手,我们可以
//继续将堆栈遍历到父测试中。
var pc [maxStackLen]uintptr
// 获取调用者的函数name
n := runtime.Callers(2, pc[:])
t = &T{ // 创建一个新的 testing.T 用于执行子测试
common: common{
barrier: make(chan bool),
signal: make(chan bool, 1),
name: testName,
parent: &t.common,
level: t.level + 1,
creator: pc[:n],
chatty: t.chatty,
},
context: t.context,
}
t.w = indenter{&t.common}
if t.chatty != nil {
t.chatty.Updatef(t.name, "=== RUN %s\n", t.name)
}
//而不是在调用之前减少此测试的运行计数
//tRunner并在之后增加它,我们依靠tRunner保持
//计数正确。这样可以确保运行一系列顺序测试
//而不会被抢占,即使它们的父级是并行测试。这
//如果*parallel==1,则可以特别减少意外。
go tRunner(t, f)
if !<-t.signal {
//此时,FailNow很可能是在
//其中一个子测验的家长测验。继续中止链的上行。
runtime.Goexit()
}
return !t.failed
}
Run函数启动一个单独的协程来运行名字为name的子测试f,并且会阻塞等待其执行结束,除非子测试f显式
调用t.Parallel将自己变为一个可并行的测试,最后返回 bool 类型的测试结果。
比如,挡在测试 func TestXxx(t *testing.T)
中调用 Run(name, f)
时,
Run将启动一个名为TestXxx/name
的子测试。
另外,所有的测试,包括 func TestXxx(t *testing.T)
自身,都是由testMain
使用Run方法启动。
每启动一个子测试都会创建一个testing.T
变量,该变量继承当前测试的部分属性,然后以新协程去执行,当前测试会在子测试结束后返回子测试的结果。
子测试退出条件要么是子测试执行结束,要么是子测试设置了Parallel,否则是异常退出。
5.4 启动并发测试 Parallel
Parallel方法将当前测试已加入并发队列
// 与此测试并行运行的并行信号(且仅与)
// 其他平行测试。当测试由于使用而多次运行时
// -test.count或-test.cpu,单个测试的多个实例从未在中运行
// 彼此平行。
func (t *T) Parallel() {
// 重复调用
if t.isParallel {
panic("testing: t.Parallel called multiple times")
}
// 先设置环境变量
if t.isEnvSet {
panic("testing: t.Parallel called after t.Setenv; cannot set environment variables in parallel tests")
}
t.isParallel = true
//我们不想把等待串行测试的时间包括在内
//在测试持续时间内。记录到目前为止经过的时间,并重置
//计时器之后。
t.duration += time.Since(t.start)
//添加到要由父级发布的测试列表中。
t.parent.sub = append(t.parent.sub, t)
t.raceErrors += race.Errors()
if t.chatty != nil {
//不幸的是,即使PAUSE表示命名测试是*no
//运行时间较长*,cmd/test2json将其解释为更改活动测试
//用于日志解析。我们可以修复cmd/test2json,但是
//不会修复已经shell的第三方工具的现有部署
//向外扩展到cmd/test2json的旧版本——因此仅修复cmd/test1json
//目前还不够。
t.chatty.Updatef(t.name, "=== PAUSE %s\n", t.name)
}
// 当前测试即将进入并发模式,表示测试结束,这样父测试就不会等待并退出 Run
t.signal <- true // 释放调用测试。
<-t.parent.barrier // 等待父测试完成。
// 阻塞等待并发调度
t.context.waitParallel()
if t.chatty != nil {
t.chatty.Updatef(t.name, "=== CONT %s\n", t.name)
}
// 重置时间,第二段耗时
t.start = time.Now()
t.raceErrors += -race.Errors()
}
在testContext中,启动一个并发测试测试后,当并发数达到最大时,并不会马上开始执行,
而是需要等待一个测试执行完成后,才会启动一个等待的测试,等待时间不会计入测试的执行耗时中。
在Run里面,启动一个测试后,会等待测试执行完成后,才会启动下一个。 如果是并发,那么就不需要等待了。
通过 t.signal 通知父测试,父测试从Run中唤醒,继续执行。
父测试唤醒后继续执行,结束后进入defer,在defer中将启动所有子测试,并等待子测试执行结束。