【GO】GO Testing源码学习

问题起源

问题起源于项目中单测代码中多次调用了sqlmock代码导致结果出现问题,觉得Testing中的并发可能是问题诱因,后来通过看源码发现所用的方式为串行执行方式,后经过实验为对sqlmock的多次同一sql语句的mock导致结果匹配出现问题。


for _, tt := range tests {
	t.Run(tt.name, func(t *testing.T) {
		c := &Config{
			ID:        tt.fields.ID,
			Label:     tt.fields.Label,
			ToolID:    tt.fields.ToolID,
			Cmd:       tt.fields.Cmd,
			Target:    tt.fields.Target,
			Scope:     tt.fields.Scope,
			Action:    tt.fields.Action,
			CreatedAt: tt.fields.CreatedAt,
			UpdatedAt: tt.fields.UpdatedAt,
		}
		patches, patchesin := tt.mock()
		got, err := c.Insert(tt.args.ctx, tt.args.matcher)
		if (err != nil) != tt.wantErr {
			t.Errorf("Insert() error = %v, wantErr %v", err, tt.wantErr)
			return
		}
		if got != tt.want {
			t.Errorf("Insert() got = %v, want %v", got, tt.want)
		}
		patches.Reset()
		patchesin.Reset()
	})
}

该方式为我们在项目中可以通过golang插件直接生成的Test代码,也是我们经常会使用的调用方式。项目中我们使用了gomokey和sqlmock的框架,为了我们的单测的可读性和逻辑性更强我们使用mock函数对所用的代码进行封装,对所用的测试用例我们只需要修改其中的值,就可以满足多个单测需求。

mock: func() (*gomonkey.Patches, *gomonkey.Patches) {
	sqlMock.ExpectBegin()
	sqlMock.ExpectExec("INSERT INTO `config`").
	WillReturnResult(sqlmock.NewResult(1, 1))
	sqlMock.ExpectCommit()
	outputs := []gomonkey.OutputCell{
		{Values: gomonkey.Params{&Matcher{}}},
	}
	patches := gomonkey.ApplyFuncSeq(GetMatcherHandle, outputs)
	outputsin := []gomonkey.OutputCell{
		{Values: gomonkey.Params{uint(123), nil}},
	}
	patchesin := gomonkey.ApplyMethodSeq(reflect.TypeOf(&Matcher{}), "Insert", outputsin)
		return patches, patchesin
	},

由于上面的代码分装方式,结果我们在多次test之间对sqlMock的相同insert语句进行了不同结果的mock,导致最后的sql语句匹配出现问题,刚开始考虑是Testing的并发导致相关问题,冲突代码如下:

	sqlMock.ExpectBegin()
	sqlMock.ExpectExec("INSERT INTO `config`").
	WillReturnResult(sqlmock.NewResult(1, 1))
	sqlMock.ExpectCommit()

Testing源码

T结构重组

	atomic.StoreInt32(&t.hasSub, 1)//原子性操作
	testName, ok, _ := t.context.match.fullName(&t.common, name)
	if !ok || shouldFailFast() {
		return true
	}
	// Record the stack trace at the point of this call so that if the subtest
	// function - which runs in a separate stack - is marked as a helper, we can
	// continue walking the stack into the parent test.  记录堆栈,为了子堆栈可以返回到父堆栈
	var pc [maxStackLen]uintptr
	n := runtime.Callers(2, pc[:])
	t = &T{
		common: common{
			barrier: make(chan bool),//父子通信信道
			signal:  make(chan bool),//完成通信信道
			name:    testName,
			parent:  &t.common,
			level:   t.level + 1,
			creator: pc[:n],
			chatty:  t.chatty,//同步输出print
		},
		context: t.context,
	}
	t.w = indenter{&t.common}

首先进入Testing的Run函数之后我们可以发现,比较关键的是run首先对数据进行了处理,也就是得到testName,接下来比较关键的就是,会重新生成一个新的T,根据之前我们叫它子T,根据父T即现在的t赋值,同时将父T引用赋值到我们子T的parent值中,形成我们需要的T并取地址赋值给原来的t,最后的结果就是这些T会形成如下的一个链表

在这里插入图片描述

其中有比较关键的两个信道变量barrier和signal,barrier在后面主要负责父test对子test的消息传递,也就是父子go程之间的通信,而signal则负责对run go程和tRunner go程的通信。其中还有chatty变量,该变量是chattyPrinter指针类型,我的理解就是满足我们的同步输出,最后将我们的输出一起进行打印

go程通信

    // Instead of reducing the running count of this test before calling the
	// tRunner and increasing it afterwards, we rely on tRunner keeping the
	// count correct. This ensures that a sequence of sequential tests runs
	// without being preempted, even when their parent is a parallel test. This
	// may especially reduce surprises if *parallel == 1. 取代运行数量调用之前减少之后增加,用tRunner保证。保证串行不会被强占,即使父任务是并行
	go tRunner(t, f)
	if !<-t.signal {//终止信号 FailNow被调用
		// At this point, it is likely that FailNow was called on one of the
		// parent tests by one of the subtests. Continue aborting up the chain.
		runtime.Goexit()
	}
	return !t.failed

通过源码我们可以发现,其实如果只是对t的多次调用我们是通过signal这个信道进行通信,也就是在tRnner go程中对signal进行发送,在这里进行接收我们就可以继续程序的执行,否则我们程序就阻塞在该处等待,也就是说如果我们不调用t的Parallel函数,其实整个的执行流程就和串行是一样的效果

t.report() // Report after all subtests have finished.所有子任务结束

	// Do not lock t.done to allow race detector to detect race in case
	// the user does not appropriately synchronizes a goroutine.  不能lock
	t.done = true  //任务结束
	if t.parent != nil && atomic.LoadInt32(&t.hasSub) == 0 {
		t.setRan()
	}
	t.signal <- signal  //子failnow且有完成的父test停止

当我们完成我们的f函数执行之后,go程进行report调用,也就是对结果进行报告之后就会对信道signal进行发送,传入信号,接下来在Run中的go程阻塞处就会收到我们发送的值,要么进行退出,要么进行返回,也就是整个逻辑流程和串行的结果是相同的。

Testing并发

要是我们只要如此进行test的串行调用,使用signal信道进行同步其实就没有什么意义了,其实在Testing中是可以进行Testing的并发的,我们来看一下google官方给的示例:

func TestGroupedParallel(t *testing.T) {
    for _, tc := range tests {
        tc := tc // capture range variable
        t.Run(tc.Name, func(t *testing.T) {
            t.Parallel()
            ...
        })
    }
}

Parallel
我们看到示例中调用了Parallel这个函数,这个就是我们实现并发的关键,我们来看Parallel中的源码:

// Add to the list of tests to be released by the parent.  加到父亲的test里
	t.parent.sub = append(t.parent.sub, t)
	t.raceErrors += race.Errors()

	if t.chatty != nil {
		// Unfortunately, even though PAUSE indicates that the named test is *no
		// longer* running, cmd/test2json interprets it as changing the active test
		// for the purpose of log parsing. We could fix cmd/test2json, but that
		// won't fix existing deployments of third-party tools that already shell
		// out to older builds of cmd/test2json — so merely fixing cmd/test2json
		// isn't enough for now.
		t.chatty.Updatef(t.name, "=== PAUSE %s\n", t.name)
	}

	t.signal <- true   // Release calling test. t这个任务结束
	<-t.parent.barrier // Wait for the parent test to complete.  等待父test完成
	t.context.waitParallel()//增加并行数量

源码中比较关键的就是将子test加到了parent中的sub里,也就是在接下来的执行中我们的父test是需要等待所有的子test执行完的,其中完成并发的关键就是我们将signal这个信道提前发送,也就是我们在这里Run go程中就会接收到我们的信号,执行完毕,开启下一个go程的创建,从而实现我们的多go程的创建,最后实现我们的并发。

父子test的通信

	if len(t.sub) > 0 {//父任务
		// Run parallel subtests.完成并行子任务
		// Decrease the running count for this test.
		t.context.release()//该任务完成释放该任务数量
		// Release the parallel subtests.
		close(t.barrier)//关闭信道
		// Wait for subtests to complete.
		for _, sub := range t.sub {
			<-sub.signal
		}//阻塞等待子go程信号
		cleanupStart := time.Now()
		err := t.runCleanup(recoverAndReturnPanic)
		t.duration += time.Since(cleanupStart)
		if err != nil {
			doPanic(err)
		}
		if !t.isParallel {
			// Reacquire the count for sequential tests. See comment in Run. 再次获取顺序任务
			t.context.waitParallel()//增加任务数量,可以开始并行
		}
	} else if t.isParallel {//最后一个并行任务
	// Only release the count for this test if it was run as a parallel  只释放这个test的数量
			// test. See comment in Run method.
			t.context.release() 
		}

我们可以看到当子test发送signal消息之后就会进行阻塞等待我们的barrier信道,父test执行完之后就会调用release之后就会发送我们的barrier信号,收到信号之后子test的tRnner go程就会在barrier的阻塞处继续执行,释放我们的并发子test。

然后我们可以看到父go程也开开始阻塞等待子test的signal完成信号,也就是说父test必须等待所有子test完成之后才会进行接下来的运行。
在这里插入图片描述

这些大概就是我理解的在Testing中的并发执行的流程,但是自己没有把所有代码都看了,还有很多没有兼顾到,难免会有错误,希望大家能够不吝赐教。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值