Go-知识测试-单元测试
- [1. 定义](#1. 定义)
- [2. 使用](#2. 使用)
- [3. testing.common 测试基础数据](#3. testing.common 测试基础数据)
- [4. testing.TB 接口](#4. testing.TB 接口)
- [5. 单元测试的原理](#5. 单元测试的原理)
-
- [5.1 context 单元测试的调度](#5.1 context 单元测试的调度)
-
- [5.1.1 等待并发执行 testContext.waitParallel](#5.1.1 等待并发执行 testContext.waitParallel)
- [5.1.2 并发测试结束 testContext.release](#5.1.2 并发测试结束 testContext.release)
- [5.2 测试执行 tRunner](#5.2 测试执行 tRunner)
- [5.3 启动测试 Run](#5.3 启动测试 Run)
- [5.4 启动并发测试 Parallel](#5.4 启动并发测试 Parallel)
1. 定义
单元测试是指对软件中的最小可测试单元进行检查和验证,比如对一个函数的测试。
单元测试要保证测试文件以_test.go
结尾。
测试方法必须以TestXxx
开头。
测试文件可以与源码处于同一目录,也可以处于单独的目录。
2. 使用
比如
执行单个TestXxx
执行整个测试文件
使用 go test
即可触发测试
3. testing.common 测试基础数据
每个单元测试都有一个入参t *testing.T
,结构定义如下:
Go
type T struct {
common
isEnvSet bool
context *testContext // For running tests and subtests.
}
T 组合了 common 类型
Go
// 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,单元测试和性能测试通过该接口获取基础能力。
Go
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
类型:
Go
type T struct {
common
isParallel bool // 是否需要并发
isEnvSet bool // 是否设置环境变量
context *testContext //用于运行测试和子测试。
}
5.1 context 单元测试的调度
context 是调度单元测试的关键:
Go
// 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是并行标志的副本。
}
其初始化代码
Go
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()
中实现
Go
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 方法释放一个信号,用于启动其他等待并发测试的函数
Go
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
Go
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
Go
// 将运行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方法将当前测试已加入并发队列
Go
// 与此测试并行运行的并行信号(且仅与)
// 其他平行测试。当测试由于使用而多次运行时
// -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中将启动所有子测试,并等待子测试执行结束。