在单元测试中使用time.Sleep函数
flaky test是一种不可靠的测试现象:即在同样的软件代码和配置环境下,得不到确定(有时成功、有时失败)的测试结果。不确定的测试被认为是测试中的最大的障碍之一,因为它的调试成本很高,并且会破坏我们对测试准确性的信心。在Go语言测试中调用time.Sleep函数可能是一个明显的信号,表明代码可能存在脆弱性。事实上,在测试并发程序的时候使用time.Sleep是相当频繁的. 在本文中,我们可以学习到从测试中删除睡眠(time.Sleep)以防止编写不稳定测试的具体方法。
下面通过一个具体的例子进行说明。程序中定义了一个Handler结构体,结构体包含n和publisher两个字段,通过publisher发布Foo切片的前n元素。该结构体有一个getBestFoo方法,该方法会返回一个Foo对象,并启动一个在后台执行作业的goroutine. 在函数内部实现上,调用getFoos函数获取一个Foo切片,并将切片的第一个元素返回,同时将Foo切片中的前n个元素传给h.publisher的Publish方法。
代码语言:javascript复制type Handler struct {
n int
publisher publisher
}
type publisher interface {
Publish([]Foo)
}
func (h Handler) getBestFoo(someInputs int) Foo {
foos := getFoos(someInputs)
best := foos[0]
go func() {
if len(foos) > h.n {
foos = foos[:h.n]
}
h.publisher.Publish(foos)
}()
return best
}
我们该如何测试这个功能呢?测试getBestFoo的响应直接通过返回值断言即可判断,但是还想检查传递给Publish的内容怎么办?我们可以通过Mock publisher接口模拟它的行为,然后记录调用Publish方法时传递给它的参数。现在问题来了,在什么时候检查传递给Publish方法的Foo切片呢?因为getBestFoo中启动一个goroutine来执行Publish操作,goroutine调度的时机是无法预知的,所以执行Publish的时间是不确定的,为了防止在检查前还没有执行,一种可能的方法是在检查前休眠几毫秒。
代码语言:javascript复制type publisherMock struct {
mu sync.RWMutex
got []Foo
}
func (p *publisherMock) Publish(got []Foo) {
p.mu.Lock()
defer p.mu.Unlock()
p.got = got
}
func (p *publisherMock) Get() []Foo {
p.mu.RLock()
defer p.mu.RUnlock()
return p.got
}
func TestGetBestFoo(t *testing.T) {
mock := publisherMock{}
h := Handler{
publisher: &mock,
n: 2,
}
foo := h.getBestFoo(42)
// Check foo
time.Sleep(10 * time.Millisecond)
published := mock.Get()
// Check published
}
上面的程序定义了一个publisherMock结构实现了publisher接口,用于模拟Publish行为,该结构中的mu用于保护对字段got访问。在进行单元测试的时候,先调用time.Sleep函数休眠10毫秒,然后再调用mock.Get
获取传递给Publish的参数,并对参数进行检查。
严格来说,测试函数TestGetBestFoo
执行的结果是不稳定的,不能严格保证延迟10毫秒就可以了(上面程序延迟的是10毫秒,一般来说这个时间足够了,但是还是不能严格保证,因为goroutine调度时机不是我们控制的)。
有哪些方法可以改进上述单元测试呢?第一种方法是采用重试操作,多判断几次。例如,可以编写一个函数,该函数接收有断言函数、最大重试次数和等待时间三个参数,它执行多次检查操作,每次检查完休眠一会。具体实现代码如下:
代码语言:javascript复制func assert(t *testing.T, assertion func() bool,
maxRetry int, waitTime time.Duration) {
for i := 0; i < maxRetry; i {
if assertion() {
return
}
time.Sleep(waitTime)
}
t.Fail()
}
上述函数中会对断言进行检查,并在重试一定次数后失败。断言函数assert中虽然也在使用time.Sleep, 但是我们可以传递给它更短的等待时间,相比前面的TestGetBestFoo函数,可以缩短等待时间。
代码语言:javascript复制assert(t, func() bool {
return len(mock.Get()) == 2
}, 30, time.Millisecond)
像上面这样,传递给assert函数最大等待时间为1毫秒,并且配置最多尝试重试30次,如果在前10次尝试中测试成功,相比前面休眠10毫秒,会减少执行等待时间。因此,采用重试策略比前面被动休眠更好。
「NOTE:一些测试库(例如testify)也提供重试功能。例如,在testify中,我们可以使用Eventually函数来实现上面的重试等待功能。」
第二种改进方法是采用同步策略,可以使用通道(channel)来同步goroutine。例如,在模拟publisher接口时,将Foo切片数据发送到通道中。下面就是采用channel的改进版本,发布者publisherMock将接收到的数据发送到通道p.ch中,在测试程序TestGetBestFoo中调用publisherMock进行模拟,并根据接收到的值 mock.ch 进行断言。为了确保不会永远等待 mock.ch 问题产生,可以实现一个超时策略,例如,可以在select 中使用 time.After 进行超时保护退出。
代码语言:javascript复制type publisherMock struct {
ch chan []Foo
}
func (p *publisherMock) Publish(got []Foo) {
p.ch <- got
}
func TestGetBestFoo(t *testing.T) {
mock := publisherMock{
ch: make(chan []Foo),
}
defer close(mock.ch)
h := Handler{
publisher: &mock,
n: 2,
}
foo := h.getBestFoo(42)
// Check foo
if v := len(<-mock.ch); v != 2 {
t.Fatalf("expected 2, got %d", v)
}
}
上面两种消除测试中的不确定性改进方法,哪种更好呢?是重试还是采用同步方式。一般来说,如果能够同步,采用同步方式是默认选项。实际上,如果设计得当,能够将等待时间约束限制在某个值以内,并且使程序具有完全的确定性。如果不能应用同步方式,我们应该重新考虑自己的设计是否有问题,对于确实不能用同步实现的,应该使用重试方法,无论如何,这也比被动休眠一段时间更好。