如何对使用时间API的函数进行健壮性的测试?
有些函数操作会使用到时间API,例如有时候需要获取当前时间。在对这些函数进行单元测试的时候,由于执行结果与时间有关,导致编写健壮的单测代码有时候非常困难。本文将通过一个具体例子来说明,并分析解决方法。当然,本文只是起到抛砖引玉的作用,很难覆盖所有的情况和场景,而是提供有关使用时间API编写更健壮函数测试的指导。
假设一个应用程序接收来自存储在内存缓存中的事件。现在,我们将定义一个Cache结构体,用于保存最近的事件。该结构体对外提供三个方法:
- 向Cache中追加事件
- 从Cache中获取所有事件
- 清理掉Cache中给定时间之前的事件(我们主要关注此方法)
上述方法实现需要访问当前时间,我们使用time.Now()完成最后一个方法中的逻辑(假定所有事件已按时间排序),实现代码如下。代码中的变量t表示当前时间减去给定时间值之后的时间点,因为c.events中的事件已按时间排序,所以在循环遍历中一旦遇到当前检查事件的时间在t之后便可终止,并将当前及之后所有的事件保留(去掉切片中前面时间比较久的事件)。
代码语言:javascript复制type Cache struct {
mu sync.RWMutex
events []Event
}
type Event struct {
Timestamp time.Time
Data string
}
func (c *Cache) TrimOlderThan(since time.Duration) {
c.mu.RLock()
defer c.mu.RUnlock()
t := time.Now().Add(-since)
for i := 0; i < len(c.events); i {
if c.events[i].Timestamp.After(t) {
c.events = c.events[i:]
return
}
}
}
如何对上面的方法进行测试呢?我们可以使用time.Now获取当前时间来创建事件的时间戳,单元测试代码如下。为了构造事件产生于过去的某个时刻,通过将当前时间减去一小段时间。下面程序中构造了3个事件,产生的时间分别为当前时间的20毫秒前、10毫秒前和10毫秒后。然后调用cache.TrimOlderThan清理掉15毫秒前的事件,最后进行断言处理。
代码语言:javascript复制func TestCache_TrimOlderThan(t *testing.T) {
events := []Event{
{Timestamp: time.Now().Add(-20 * time.Millisecond)},
{Timestamp: time.Now().Add(-10 * time.Millisecond)},
{Timestamp: time.Now().Add(10 * time.Millisecond)},
}
cache := &Cache{}
cache.Add(events)
cache.TrimOlderThan(15 * time.Millisecond)
got := cache.GetAll()
expected := 2
if len(got) != expected {
t.Fatalf("expected %d, got %d", expected, len(got))
}
}
上面的单测函数有一个主要缺点,如果执行测试的机器突然很忙,执行cache.TrimOlderThan清理操作后,获取到的事件可能比预期的少。虽然可以通过调整构造Event的时间戳Timestamp,降低测试失败的概率。然而这种处理方法并不是在任何情况下都是有效的,例如,如果Timestamp字段是不可导出的,就不可能传递特定的时间值给它,可能的处理是在单元测试中添加睡眠逻辑。问题原因与TrimOlderThan的实现逻辑有关,因为在它的函数体中调用了time.Now(),使得实现健壮的单元测试变得非常困难。下面开始讨论处理这种问题的两种方法。
第一种处理思路是让获取当前时间逻辑依赖于Cache结构体。在生产环境中,传入/赋值真正地实现。而在单元测试中,我们可以给它赋值一个假的实现。处理依赖项有不同的实现方法,例如通过接口或函数类型。在本文的例子中,由于只依赖一个time.Now()方法,我们可以定义一个函数类型:
代码语言:javascript复制type now func() time.Time
type Cache struct {
mu sync.RWMutex
events []Event
now now
}
Cache结构体中的字段now的类型是一个返回time.Time的函数。在创建Cache的构造函数(NewCache)中,我们可以将实际的时间time.Now赋值给它。
代码语言:javascript复制func NewCache() *Cache {
return &Cache{
events: make([]Event, 0),
now: time.Now,
}
}
由于Cache结构体中的now字段不可导出,因此外部调用无法直接操作它。在单元测试的时候,我们赋值给now一个绝对时间(像下面2020-01-01T12:00:00.04Z),而不是通过time.Now()获取的当前时间(它是可变的,与程序执行时时间有关),这是为了单测构造的一个假实现。
代码语言:javascript复制func TestCache_TrimOlderThan(t *testing.T) {
events := []Event{
{Timestamp: parseTime(t, "2020-01-01T12:00:00.04Z")},
{Timestamp: parseTime(t, "2020-01-01T12:00:00.05Z")},
{Timestamp: parseTime(t, "2020-01-01T12:00:00.06Z")},
}
cache := &Cache{now: func() time.Time {
return parseTime(t, "2020-01-01T12:00:00.06Z")
}}
cache.Add(events)
cache.TrimOlderThan(15 * time.Millisecond)
// ...
}
func parseTime(t *testing.T, timestamp string) time.Time {
// ...
}
上述程序在创建*Cache对象的时候,将我们给定的时间注入赋值给了now字段,通过这种方法,即使在最坏的情况下,测试的结果也是确定的,使得程序具有很好的健壮性。
「NOTE:除了在结构体Cache中添加now字段,也可以使用全局变量的方式,像下面这样定义一个全局字段now,在获取当前时间的时候通过全局变量now获取。一般来说,不建议采用全局变量的方式,因为它是可变的又是共享的,当并发访问的时候可能导致一些问题。例如在本文的例子中,它会导致测试不再是独立的,不能并行运行,因为都依赖于同一个共享变量。所以如果可能的话,尽量将依赖内容定义在结构体中,这样各测试之间是隔离的,能够并行运行。」
代码语言:javascript复制var now = time.Now
通过将依赖项定义为变量处理思路是具有扩展性的。例如,如果函数内部处理中会调用time.After该怎么办呢?同样采用上面的方法,我们可以在结构体中添加一个after变量字段,将其定义为一个函数类型,函数签名同time.After. 当然也可以采用接口来实现,创建一个具有Now和After两个方法的接口。
第一种处理方法有一个缺点,如果我们在包外创建单元测试,则不可能直接操作依赖项now,因为它是不可导出的。在这种情况下,可以采用下面讨论的第二种处理方法,让调用方提供当前的时间,像下面这样,调用TrimOlderThan需要传入获取时间参数now.
代码语言:javascript复制func (c *Cache) TrimOlderThan(now time.Time, since time.Duration) {
// ...
}
我们还可以处理的更精炼一些,将传给TrimOlderThan的两个参数合并为一个。合并后效果如下,此时时间t的含义为要清理事件的特定时间,即它的值等于now获取的时间值减去since后的值。
代码语言:javascript复制func (c *Cache) TrimOlderThan(t time.Time) {
// ...
}
经过这样的处理后,将清理时间点的计算抛给调用方来处理,由调用方提供。
代码语言:javascript复制cache.TrimOlderThan(time.Now().Add(time.Second))
同样,在单元测试的时候,像下面这样,传入清理事件的时间点即可。
代码语言:javascript复制func TestCache_TrimOlderThan(t *testing.T) {
// ...
cache.TrimOlderThan(parseTime(t, "2020-01-01T12:00:00.06Z").
Add(-15 * time.Millisecond))
// ...
}
第二种处理方法可能是最简单的,因为它不需要添加用于保存依赖的字段。通常来说,在对调用有时间API函数进行测试时要格外小心谨慎,否则可能会导致Go语言中常见100问题-#86 Sleeping in unit tests中的flaky test问题。本文分析讨论了处理该问题的两种方法,一种处理方法是将对时间函数访问作为单元测试中伪造依赖项的一部分,即在结构体中添加依赖项字段,这样在进行单元测试时可以替换成自己的假实现。另一种处理方法是重新设计程序的API接口,将依赖项信息留给调用方提供,例如,在本文中将获取清理事件时的时间作为函数参数,留给调用方设置。两种方法比较起来,当然第二种更简单,但是它也有一定的局限性,不是任何情况都可以采用该方法解决。