1. go test单元测试
1.1 为什么要进行单元测试
单元测试(Unit Tests, UT) 是一个优秀项目不可或缺的一部分,特别是在一些频繁变动和多人合作开发的项目中尤为重要。因为有这样的情况,项目很大,启动环境很复杂,你优化了一个函数的性能,或是添加了某个新的特性,如果部署在正式环境上之后再进行测试,成本太高。对于这种场景,几个小小的测试用例或许就能够覆盖大部分的测试场景。而且在开发过程中,效率最高的莫过于所见即所得了,单元测试也能够帮助你做到这一点,假如你一口气写下一千行代码,debug 的过程也不会轻松,如果在这个过程中,对于一些逻辑较为复杂的函数,同时添加一些测试用例,即时确保正确性,最后集成的时候,可以提升效率。
高内聚,低耦合
是软件工程的原则,同样,对测试而言,函数/方法写法不同,测试难度也是不一样的。职责单一,参数类型简单,与其他函数耦合度低的函数更容易测试。
1.2 testing库实现简单测试样例
Go 语言推荐测试文件和源代码文件放在一块,测试文件以 _test.go
结尾。比如,我构建一个package底下 有 calculate.go
一个文件,则它的测试文件为 calculate_test.go
![image-20220501183006415](https://i-blog.csdnimg.cn/blog_migrate/4ed11600f6d0d6e4a463ee9319970ec6.png)
calcuelte.go
的代码如下:里面有两个方法 Add和Muti
package main
func Add(a int ,b int) int{
return a+b
}
func Muti (a int,b int)int{
return a*b
}
calc_test.go
中的测试用例如下:
package main
import "testing"
func TestAdd(t *testing.T) {
if ans:=Add(1,2); ans!=3{
t.Errorf("1+2 expected 3 but %d",ans)
}
}
func TestMuti(t *testing.T) {
if ans:=Muti(1,2);ans!=2{
t.Errorf("1*2 expected 2 but %d",ans)
}
}
- 测试用例名称一般命名为
Test
加上待测试的方法名。 - 测试用的参数有且只有一个,在这里是
t *testing.T
。 - 基准测试(benchmark)的参数是
*testing.B
,TestMain 的参数是*testing.M
类型。
涉及到的一些基本命令
go test
,就可以执行该包下的所有测试样例了。
![image-20220501183029791](https://i-blog.csdnimg.cn/blog_migrate/1a641759948676cd3af07b73ac079336.png)
go test -v
,-v
参数会显示每个用例的测试结果
![image-20220501183041317](https://i-blog.csdnimg.cn/blog_migrate/646faf0fb319f9fb91f07e6eb503d99f.png)
-
go test -cover
测试覆盖率测试覆盖率是代码被测试套件覆盖的百分比。
通常使用的都是语句的覆盖率,也就是在测试中至少被运行一次的代码占总代码的比例。
-
go test -run 方法名 -v
:可以只运行其中一个测试用例,该参数支持通配符*
,和部分正则表达式,例如^
、$
。
![image-20220501183126850](https://i-blog.csdnimg.cn/blog_migrate/47838def4372ef07d5228b71e11c3696.png)
1.3 编写子测试
子测试是 Go 语言内置支持的,可以在某个测试用例中,根据测试场景使用 t.Run
创建不同的子测试用例:
func TestMuti2(t *testing.T) {
t.Run("test1_positive", func(t *testing.T) {
if ans:=Muti(2,2);ans!=4{
t.Fatalf("2*2 expected 4 but %d",ans)
}
})
t.Run("test2_negtive", func(t *testing.T) {
if ans:=Muti(-1,-2);ans!=2{
t.Fatalf("-1 * -2 expected 2 but %d",ans)
}
})
}
-
测试失败时使用
t.Error/t.Errorf
:遇错误不停,还会继续执行其他的测试用例,也可以使用t.Fatal/t.Fatalf
:后者遇错即停。运行结果:可以看到这个测试中运行了两个子测试,也可以通过
go test -run TestMul/pos -v
单独运行其中一个子测试。
- 多个子测试可以将测试数据组织到切片中
func TestAdd2(t *testing.T) {
cases:=[]struct{
Name string
A,B,Expected int
}{
{"test1",1,2,3},
{"test2",2,4,6},
{"test3",1,-1,0},
}
for _,c:=range cases{
t.Run(c.Name, func(t *testing.T) {
if ans:=Add(c.A,c.B);ans!=c.Expected{
t.Fatalf("%d *%d expected %d but %d",c.A,c.B,c.Expected,ans)
}
})
}
}
- 运行
go test -run TestAdd2 -v
得到:
![image-20220501183156896](https://i-blog.csdnimg.cn/blog_migrate/e30c1b6e69c71f6e1b25593dad4d86cf.png)
所有用例的数据组织在切片 cases
中,看起来就像一张表,借助循环创建子测试。这样写的好处有:
- 新增用例非常简单,只需给 cases 新增一条测试数据即可。
- 测试代码可读性好,直观地能够看到每个子测试的参数和期待的返回值。
- 用例失败时,报错信息的格式比较统一,测试报告易于阅读。
如果数据量较大,或是一些二进制数据,推荐使用相对路径从文件中读取。
1.4 帮助函数(Helpers)
对一些重复的逻辑,抽取出来作为公共的帮助函数(helpers),可以增加测试代码的可读性和可维护性。 借助帮助函数,可以让测试用例的主逻辑看起来更清晰。
比如,可以将上式对func Muti方法的测试函数抽取一个公共的帮助函数
//帮助函数
type muticase struct {
A,B,Expected int
}
func createMutiCase(t *testing.T,m *muticase){
if ans:=Muti(m.A,m.B);ans!=m.Expected{
t.Fatalf("%d *%d expected %d but %d",m.A,m.B,m.Expected,ans)
}
}
func TestMuti3(t *testing.T) {
createMutiCase(t,&muticase{1,2,2})
createMutiCase(t,&muticase{2,3,5})
createMutiCase(t,&muticase{1,-2,-2})
}
假设故意创建了一个错误的测试用例,运行 go test -run TestMuti3 -v
,用例失败,会报告错误发生的文件和行号信息:
![image-20220501183224726](https://i-blog.csdnimg.cn/blog_migrate/9fb87a25dfbc4006d446c2222be3954b.png)
可以看到,错误发生在第54行,也就是帮助函数 createMulTiCase
内部。因为TestMuti3中调用了这个帮助函数3次,这样就不知道是哪行调用时发生了错误。有些帮助函数还可能在不同的函数中被调用,报错信息都在同一处,不方便问题定位。
因此,Go 语言在 1.9 版本中引入了 t.Helper()
,用于标注该函数是帮助函数,报错时将输出帮助函数调用者的信息,而不是帮助函数的内部信息。
- 因此,可以为createMutiCase 添加t.Helper(),可以在报错时显示这个帮助函数调用者的信息。
![image-20220501183237619](https://i-blog.csdnimg.cn/blog_migrate/9746827f8b6feb4f7426e4ce6cc747c0.png)
再次运行 go test -run TestMuti3 -v
![image-20220501183246781](https://i-blog.csdnimg.cn/blog_migrate/f787fd318553f83f99a60d6d2c9ddf95.png)
可以看到:错误已经定位到了帮助函数的调用者上
关于 helper
函数的 2 个建议:
- 不要返回错误, 帮助函数内部直接使用
t.Error
或t.Fatal
即可,在用例主逻辑中不会因为太多的错误处理代码,影响可读性。 - 调用
t.Helper()
让报错信息更准确,有助于定位。
1.5 setup 和 teardown
如果在同一个测试文件中,每一个测试用例运行前后的逻辑是相同的,一般会写在 setup 和 teardown 函数中。例如执行前需要实例化待测试的对象,如果这个对象比较复杂,很适合将这一部分逻辑提取出来;执行后,可能会做一些资源回收类的工作,例如关闭网络连接,释放文件等。标准库 testing
提供了这样的机制:
即:通过TestMain(m *testing.M)
进入,可在所有的测试运行前后加一些操作
func TestMuti3(t *testing.T) {
createMutiCase(t,&muticase{1,2,2})
createMutiCase(t,&muticase{2,3,6})
createMutiCase(t,&muticase{1,-2,-2})
}
func setup(){
fmt.Println("Before all the test")
}
func teardown(){
fmt.Println("After all the test")
}
func TestMain(m *testing.M){
setup()
code:=m.Run()
teardown()
os.Exit(code)
}
- 如果测试文件中包含函数
TestMain
,那么生成的测试将调用 TestMain(m),而不是直接运行测试。 - 调用
m.Run()
触发所有测试用例的执行,并使用os.Exit()
处理返回的状态码,如果不为0,说明有用例失败。 - 因此可以在调用
m.Run()
前后做一些额外的准备(setup)和回收(teardown)工作。
运行 go test
,输出
![image-20220501183306461](https://i-blog.csdnimg.cn/blog_migrate/73e242e84604e4ac7ccc7954971fedff.png)
1.6 Benchmark 基准测试
基准测试用例的定义如下:
func BenchmarkName(b *testing.B){
// ...
}
-
基准测试程序主要测试:执行时间复杂度、空间复杂度
-
函数名必须以
Benchmark
开头,后面一般跟待测试的函数名
- 参数为
b *testing.B
。 - 执行基准测试时,需要添加
-bench
参数。
例如:
编写一个基准测试,测试Add函数:
![image-20220501183314237](https://i-blog.csdnimg.cn/blog_migrate/4cd7f6bd84b51a98774c776623024cc4.png)
-
基准测试的代码文件必须以_test.go结尾
-
基准测试的函数必须以Benchmark开头,必须是可导出的
-
基准测试函数必须接受一个指向Benchmark类型的指针作为唯一参数
-
基准测试函数不能有返回值
-
b.ResetTimer是重置计时器,这样可以避免for循环之前的初始化代码的干扰
-
最后的for循环很重要,被测试的代码要放到循环里
-
b.N
是基准测试框架提供的,表示循环的次数,因为需要反复调用测试的代码,才可以评估性能
执行基准测试的指令:
go test -bench=. -run=none
(使用go test
命令,加上-bench=
标记,接受一个表达式作为参数,.
表示运行所有的基准测试)
因为默认情况下 go test
会运行单元测试,为了防止单元测试的输出影响我们查看基准测试的结果,可以使用-run=
匹配一个从来没有的单元测试方法,过滤掉单元测试的输出,我们这里使用none
,因为我们基本上不会创建这个名字的单元测试方法。
- 也可以使用
-run=^$
, 匹配这个规则的:
运行:go test -bench=. -run=none
结果:
![image-20220501183334619](https://i-blog.csdnimg.cn/blog_migrate/0b9cf29d0fba0ee686257e8b081eb5bb.png)
-6:表示运行时对应的GOMAXPROCS的值。
1000000000:表示运行for循环的次数也就是调用被测试代码的次数
0.2406ns/op:表示每次需要花费0.2406纳秒。
以上是测试时间默认是1秒,也就是1秒的时间,调用10000000000次,每次调用花费0.2406纳秒。
如果在运行前基准测试需要一些耗时的配置,则可以使用 b.ResetTimer()
先重置定时器,例如:
func BenchmarkHello(b *testing.B) {
... // 耗时操作
b.ResetTimer()
for i := 0; i < b.N; i++ {
fmt.Sprintf("hello")
}
}
-
go test -bench=. -benchmem -run=none
-benchmem
增加两个返回参数:可以提供每次操作分配内存的次数,以及每次操作分配的字节数。可以通过这个方法来比较标准库中int类型转为string类型的例子,比较它们的性能:
func BenchmarkSprintf(b *testing.B){ num:=10 b.ResetTimer() for i:=0;i<b.N;i++{ fmt.Sprintf("%d",num) } } func BenchmarkFormat(b *testing.B){ num:=int64(10) b.ResetTimer() for i:=0;i<b.N;i++{ strconv.FormatInt(num,10) } } func BenchmarkItoa(b *testing.B){ num:=10 b.ResetTimer() for i:=0;i<b.N;i++{ strconv.Itoa(num) } }
运行
go test -bench=. -benchmem -run=none
结果:
分析结果可以看到:stroconv包的函数的性能明显比sprintf高
1.7 并行执行基准测试
如果基准测试需要在并行设置中测试性能,则可以使用 RunParallel 辅助函数 ; 这样的基准测试一般与 go test -cpu
标志一起使用。RunParallel 会创建出多个 goroutine,并将 b.N 分配给这些 goroutine 执行,其中 goroutine 数量的默认值为 GOMAXPROCS。如果想要增加goroutine的数量,可以调用函数SetParallelism将RunParallel使用的goroutine的数量设置为p * GOMAXPROCS。RunParallel
函数将在每个 goroutine 中执行,这个函数需要设置所有 goroutine 本地的状态,并迭代直到 pb.Next
返回 false 值为止。
func BenchmarkParallel(b *testing.B) {
// 测试一个对象或者函数在多线程的场景下面是否安全
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
m := rand.Intn(100) + 1
n := rand.Intn(m)
Add(m,n)
}
})
}
2. Context包的使用
2.1 Context包的作用
我们会在用到很多东西的时候都看到context,就比如grpc框架。
它的原理总结:
- 当前协程取消了,可以通知所有由它创建的子协程退出
- 当前协程取消了,不会影响到创建它的父级协程的状态
- 扩展了额外的功能:超时取消、定时取消、可以和子协程共享数据
2.2、主协程退出通知子协程示例演示
2.2.1 主协程通知子协程退出
通过一个叫done的channel通道达到了这样的效果
package main
import (
"fmt"
"time"
)
func main(){
//创建done通道
done:=make(chan string)
//创建缓冲通道
messages:=make(chan int ,10)
defer close(messages)
for i:=0;i<10;i++{
messages<-i
}
//启动协程消费message消息
for i:=1;i<=3;i++{
go child(i,done,messages)
}
time.Sleep(3*time.Second) //等待子协程接收一定的消息
close(done)//结束主协程之前通知子协程
time.Sleep(3*time.Second)//等待所有的子协程输出
fmt.Printf("主协程结束")
}
func child(i int ,done<-chan string,message<-chan int){
Consume:
for {
time.Sleep(1*time.Second)
select {
case <-done:
fmt.Printf("[%d]被主线程通知结束\n",i)
break Consume
default:
fmt.Printf("[%d]接收消息:%d\n",i,<-message)
}
}
}
运行结果:
![image-20220501183429493](https://i-blog.csdnimg.cn/blog_migrate/02d1cca97f03d5bf774754290e356f2d.png)
这里,我们用一个channel的关闭做到了通知所有的消费到一半的子协程退出。
问题来了,如果子协程又要启动它的子协程,这可咋整?
2.2.2 主协程通知有子协程,子协程又有多个子协程
package main
import (
"fmt"
"time"
)
func main(){
//创建done通道
done:=make(chan string)
//创建缓冲通道
messages:=make(chan int ,10)
defer close(messages)
for i:=0;i<10;i++{
messages<-i
}
//启动协程消费message消息
for i:=1;i<=3;i++{
go child(i,done,messages)
}
time.Sleep(3*time.Second) //等待子协程接收一定的消息
close(done)//结束主协程之前通知子协程
time.Sleep(3*time.Second)//等待所有的子协程输出
fmt.Printf("主协程结束")
}
func child(i int ,done<-chan string,message<-chan int){
newDone := make(chan string)
defer close(newDone)
go childJob(i, "a", newDone)
go childJob(i, "b", newDone)
Consume:
for {
time.Sleep(1*time.Second)
select {
case <-done:
fmt.Printf("[%d]被主线程通知结束\n",i)
break Consume
default:
fmt.Printf("[%d]接收消息:%d\n",i,<-message)
}
}
}
//任务
func childJob(parent int, name string, done <-chan string) {
for {
time.Sleep(1 * time.Second)
select {
case <-done:
fmt.Printf("[%d-%v]被结束...\n", parent, name)
return
default:
fmt.Printf("[%d-%v]执行\n", parent, name)
}
}
}
运行结果:
可以看到,当1,2,3协程里面的两个子协程全部结束后,主协程结束。
这种做法如果要在子协程里面创建新的协程,必须新建done通道,所以可以引入Context包。
2.2.3 引入Context包 来控制协程的并发
package main
import (
"fmt"
"golang.org/x/net/context"
"time"
)
func main(){
ctx,cancle:=context.WithCancel(context.Background())
//创建缓冲通道
messages:=make(chan int ,10)
defer close(messages)
for i:=0;i<10;i++{
messages<-i
}
//启动协程消费message消息
for i:=1;i<=3;i++{
go child(i,ctx,messages)
}
time.Sleep(3*time.Second) //等待子协程接收一定的消息
cancle()
time.Sleep(3*time.Second)//等待所有的子协程输出
fmt.Printf("主协程结束")
}
func child(i int ,ctx context.Context,message<-chan int){
//基于父级context建立子级别context
newCtx,_:=context.WithCancel(ctx)
go childJob( newCtx,i, "a")
go childJob( newCtx, i,"b")
Consume:
for {
time.Sleep(1*time.Second)
select {
case <-ctx.Done():
fmt.Printf("[%d]被主线程通知结束\n",i)
break Consume
default:
fmt.Printf("[%d]接收消息:%d\n",i,<-message)
}
}
}
//任务
func childJob(ctx context.Context,parent int, name string) {
for {
time.Sleep(1 * time.Second)
select {
case <-ctx.Done():
fmt.Printf("[%d-%v]被结束...\n", parent, name)
return
default:
fmt.Printf("[%d-%v]执行\n", parent, name)
}
}
}
运行结果:可以通过Context包可以通过子协程退出
![image-20220501183507723](https://i-blog.csdnimg.cn/blog_migrate/83346398a96f1b36e47c9a5b4f79953f.png)
- 主要是取消通道done,并且通过Context向下传递
![image-20220501183524602](https://i-blog.csdnimg.cn/blog_migrate/412120d68074898eb36456c5b133a95c.png)
-
基于上层context再构建当前层级的context
-
监听context的退出信号:
总结:这就是context包的核心原理,链式传递context,基于context构造新的context
2.3 Context包的核心接口和方法
context接口
context是一个接口,主要包含以下4个方法
![image-20220501183600913](https://i-blog.csdnimg.cn/blog_migrate/d222fa32a693008fcd5d30e510860b89.png)
-
Deadline
返回当前context任务被取消的时间,没有设定返回ok返回false -
Done
当绑定当前的context任务被取消时,将返回一个关闭的channel -
Err
Done返回的channel没有关闭,返回nil;
Done返回的channel已经关闭,返回非空值表示任务结束的原因;
context被取消,返回Canceled。
context超时,DeadlineExceeded -
Value
返回context存储的键
emptyCtx结构体
type emptyCtx int
实现了context接口,emptyCtx没有超时时间,不能取消,也不能存储额外信息,所以emptyCtx用来做根节点,一般用Background和TODO来初始化emptyCtx
Backgroud
通常用于主函数,初始化以及测试,作为顶层的context
TODO
不确定使用什么用context的时候才会使用
valueCtx结构体
type valueCtx struct{ Context key, val interface{} }
valueCtx利用Context的变量来表示父节点context,所以当前context继承了父context的所有信息
valueCtx还可以存储键值。
Value
func (c *valueCtx) Value(key interface{}) interface{} {
if c.key == key {
return c.val
}
return c.Context.Value(key)
}
可以用来获取当前context和所有的父节点存储的key
如果当前的context不存在需要的key,会沿着context链向上寻找key对应的值,直到根节点
WithValue
可以向context添加键值
func WithValue(parent Context, key, val interface{}) Context {
if key == nil {
panic("nil key")
}
if !reflect.TypeOf(key).Comparable() {
panic("key is not comparable")
}
return &valueCtx{parent, key, val}
}
添加键值会返回创建一个新的valueCtx子节点
示例
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx := context.WithValue(context.Background(), "top", "root")
//第一层
go func(parent context.Context) {
ctx = context.WithValue(parent, "second", "child")
//第二层
go func(parent context.Context) {
ctx = context.WithValue(parent, "third", "child-child")
//第三层
go func(parent context.Context) {
//可以获取所有的父类的值
fmt.Println(ctx.Value("top"))
fmt.Println(ctx.Value("second"))
fmt.Println(ctx.Value("third"))
//不存在
fmt.Println(ctx.Value("fourth"))
}(ctx)
}(ctx)
}(ctx)
time.Sleep(1 * time.Second)
fmt.Println("end")
}
运行结果: 可以看到,子context是可以获取所有父级设置过的key
cancelCtx结构体
type cancelCtx struct {
Context
mu sync.Mutex
done chan struct{}
children map[canceler]struct{}
err error
}
type canceler interface {
cancel(removeFromParent bool, err error)
Done() <-chan struct{}
}
和valueCtx类似,有一个context做为父节点,
变量done表示一个channel,用来表示传递关闭;
children表示一个map,存储了当前context节点为下的子节点
err用来存储错误信息表示任务结束的原因
WithCancel
用来创建一个可取消的context,返回一个context和一个CancelFunc,调用CancelFunc可以触发cancel操作。
示例
package main
import (
"context"
"fmt"
"time"
)
func main() {
ctx, cancel := context.WithCancel(context.Background())
//第一层
go func(parent context.Context) {
ctx, _ := context.WithCancel(parent)
//第二层
go func(parent context.Context) {
ctx, _ := context.WithCancel(parent)
//第三层
go func(parent context.Context) {
waitCancel(ctx, 3)
}(ctx)
waitCancel(ctx, 2)
}(ctx)
waitCancel(ctx, 1)
}(ctx)
time.Sleep(3 * time.Second)
cancel()
time.Sleep(1 * time.Second)
}
func waitCancel(ctx context.Context, i int) {
for {
time.Sleep(time.Second)
select {
case <-ctx.Done():
fmt.Printf("[%d] 结束\n", i)
return
default:
fmt.Printf("[%d] 执行\n", i)
}
}
}
运行结果:可以看到,在外边调用cancel方法,所有的子goroutine都已经收到停止信号
timerCtx结构体
timerCtx是基于cancelCtx的context精英,是一种可以定时取消的context,过期时间的deadline不晚于所设置的时间d
type timerCtx struct {
cancelCtx
timer *time.Timer // Under cancelCtx.mu.
deadline time.Time
}
WithDeadline
context.WithDeadline()
则可以控制子协程的最迟退出时间
WithTimeout
如果需要控制子协程的执行时间,可以使用 context.WithTimeout
创建具有超时通知机制的 Context 对象
创建一个定时取消context,和WithDeadline差不多,WithTimeout是相对时间
2.4 总结核心原理
- Done方法返回一个channel
- 外部通过调用<-channel监听cancel方法
- cancel方法会调用close(channel)
当调用close方法的时候,所有的channel再次从通道获取内容,会返回零值和false
res,ok := <-done:
- 过期自动取消,使用了time.AfterFunc方法,到时调用cancel方法
c.timer = time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded)
})