go Test单元测试

1. go test单元测试

1.1 为什么要进行单元测试

单元测试(Unit Tests, UT) 是一个优秀项目不可或缺的一部分,特别是在一些频繁变动和多人合作开发的项目中尤为重要。因为有这样的情况,项目很大,启动环境很复杂,你优化了一个函数的性能,或是添加了某个新的特性,如果部署在正式环境上之后再进行测试,成本太高。对于这种场景,几个小小的测试用例或许就能够覆盖大部分的测试场景。而且在开发过程中,效率最高的莫过于所见即所得了,单元测试也能够帮助你做到这一点,假如你一口气写下一千行代码,debug 的过程也不会轻松,如果在这个过程中,对于一些逻辑较为复杂的函数,同时添加一些测试用例,即时确保正确性,最后集成的时候,可以提升效率。

高内聚,低耦合是软件工程的原则,同样,对测试而言,函数/方法写法不同,测试难度也是不一样的。职责单一,参数类型简单,与其他函数耦合度低的函数更容易测试。

1.2 testing库实现简单测试样例

Go 语言推荐测试文件和源代码文件放在一块,测试文件以 _test.go 结尾。比如,我构建一个package底下 有 calculate.go 一个文件,则它的测试文件为 calculate_test.go

image-20220501183006415

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
  • go test -v-v 参数会显示每个用例的测试结果
image-20220501183041317
  • go test -cover 测试覆盖率

    测试覆盖率是代码被测试套件覆盖的百分比。

    通常使用的都是语句的覆盖率,也就是在测试中至少被运行一次的代码占总代码的比例。

    image-20220501183052384
  • go test -run 方法名 -v :可以只运行其中一个测试用例,该参数支持通配符 *,和部分正则表达式,例如 ^$

image-20220501183126850

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 单独运行其中一个子测试。

    image-20220501183147633
  • 多个子测试可以将测试数据组织到切片中
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

所有用例的数据组织在切片 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

可以看到,错误发生在第54行,也就是帮助函数 createMulTiCase 内部。因为TestMuti3中调用了这个帮助函数3次,这样就不知道是哪行调用时发生了错误。有些帮助函数还可能在不同的函数中被调用,报错信息都在同一处,不方便问题定位。

因此,Go 语言在 1.9 版本中引入了 t.Helper(),用于标注该函数是帮助函数,报错时将输出帮助函数调用者的信息,而不是帮助函数的内部信息。

  • 因此,可以为createMutiCase 添加t.Helper(),可以在报错时显示这个帮助函数调用者的信息。
image-20220501183237619

再次运行 go test -run TestMuti3 -v

image-20220501183246781

可以看到:错误已经定位到了帮助函数的调用者上

关于 helper 函数的 2 个建议:

  • 不要返回错误, 帮助函数内部直接使用 t.Errort.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

1.6 Benchmark 基准测试

基准测试用例的定义如下:

func BenchmarkName(b *testing.B){
    // ...
}
  • 基准测试程序主要测试:执行时间复杂度、空间复杂度

  • 函数名必须以 Benchmark 开头,后面一般跟待测试的函数名

  • 参数为 b *testing.B
  • 执行基准测试时,需要添加 -bench 参数。

例如:

编写一个基准测试,测试Add函数:

image-20220501183314237
  • 基准测试的代码文件必须以_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
-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 结果:

    image-20220501183402026

分析结果可以看到: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框架。

它的原理总结:

  1. 当前协程取消了,可以通知所有由它创建的子协程退出
  2. 当前协程取消了,不会影响到创建它的父级协程的状态
  3. 扩展了额外的功能:超时取消、定时取消、可以和子协程共享数据

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

这里,我们用一个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)
		}
	}
}

运行结果:
image-20220501183447784

可以看到,当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
  • 主要是取消通道done,并且通过Context向下传递
image-20220501183524602
  • 基于上层context再构建当前层级的context
    image-20220501183533659

  • 监听context的退出信号:
    image-20220501183543756

总结:这就是context包的核心原理,链式传递context,基于context构造新的context

2.3 Context包的核心接口和方法

context接口

context是一个接口,主要包含以下4个方法

image-20220501183600913
  • 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
image-20220501183620877

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都已经收到停止信号
image-20220501183644809

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)
  })
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值