问题起源
问题起源于项目中单测代码中多次调用了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中的并发执行的流程,但是自己没有把所有代码都看了,还有很多没有兼顾到,难免会有错误,希望大家能够不吝赐教。