Go-知识测试-单元测试

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中将启动所有子测试,并等待子测试执行结束。

相关推荐
程序猿000001号7 小时前
探索Python的pytest库:简化单元测试的艺术
python·单元测试·pytest
我是前端小学生7 小时前
Go语言中的方法和函数
go
探索云原生11 小时前
在 K8S 中创建 Pod 是如何使用到 GPU 的: nvidia device plugin 源码分析
ai·云原生·kubernetes·go·gpu
hkNaruto14 小时前
【P2P】【Go】采用go语言实现udp hole punching 打洞 传输速度测试 ping测试
golang·udp·p2p
入 梦皆星河14 小时前
go中常用的处理json的库
golang
海绵波波10716 小时前
Gin-vue-admin(2):项目初始化
vue.js·golang·gin
每天写点bug17 小时前
【go每日一题】:并发任务调度器
开发语言·后端·golang
一个不秃头的 程序员17 小时前
代码加入SFTP Go ---(小白篇5)
开发语言·后端·golang
基哥的奋斗历程17 小时前
初识Go语言
开发语言·后端·golang
自在的LEE18 小时前
当 Go 遇上 Windows:15.625ms 的时间更新困局
后端·kubernetes·go