深入 Quartz:用 Go 写确定性时间单元测试的 Clock 模拟库(含 Loki 实战)
【免费下载链接】lokiLike Prometheus, but for logs.项目地址: https://gitcode.com/GitHub_Trending/lok/loki
导读
Quartz 是一个面向 Go 语言的时间测试库,它通过一个与标准库time高度相似的Clock接口,让业务代码在生产环境透明地走真实时钟、在单元测试中无缝切换为完全可控的 Mock 时钟。本文将以vendor/github.com/coder/quartz/下的官方 README 为主线,结合mock.go、real.go、timer.go、ticker.go等源码实现,系统讲解Clock接口设计、Advance/AdvanceNext/Peek时钟推进机制、Trap陷阱与Tag标签的高级用法,并展示该库在 Loki 仓库(如 pkg/engine/retention.go、pkg/limits/consumer.go)中的真实落地方式。读完本文,你将能写出执行快、不 flake、易读易维护的确定性时间测试。
一、为什么需要 Quartz:确定性是时间测试的命门
Quartz 的顶层目标非常明确,所有单元测试都应满足三条标准:
- 执行快(execute quickly);
- 不 flake(don't flake);
- 直白易懂、容易写(straightforward to write and understand)。
要同时做到这三点,核心就在于确定性:测试每次运行的结果必须一致,并且在执行断言前,要能轻松地把系统强制推到某个已知状态(无竞态)。而time.Sleep、runtime.Gosched()、轮询式Eventually都是“无法轻易做到这一点”的典型症状——它们本质上是靠运气等待,而不是靠机制保证。
Quartz 的思路是从源头解决问题:业务代码不再直接依赖time包,而是依赖一个抽象——quartz.Clock。生产环境注入quartz.NewReal()(真实时钟,透传标准库),测试环境注入quartz.NewMock(t)(模拟时钟,完全受控)。
1.1 Clock 接口:标准库的映射
查看 vendor/github.com/coder/quartz/clock.go,Clock接口完整覆盖了日常最常用的时间原语:
type Clock interface { NewTicker(d time.Duration, tags ...string) *Ticker TickerFunc(ctx context.Context, d time.Duration, f func() error, tags ...string) Waiter NewTimer(d time.Duration, tags ...string) *Timer AfterFunc(d time.Duration, f func(), tags ...string) *Timer Now(tags ...string) time.Time Since(t time.Time, tags ...string) time.Duration Until(t time.Time, tags ...string) time.Duration }可以看到,接口里的每个方法几乎都能在标准库time中找到对应物:NewTicker↔time.NewTicker、NewTimer↔time.NewTimer、AfterFunc↔time.AfterFunc、Since↔time.Since、Until↔time.Until。除此之外 Quartz 还额外提供了TickerFunc,后面会专门讲。每个方法末尾都接受可选的tags ...string,这是 Quartz 用于支持“陷阱”高级功能的关键设计,对真实时钟则完全忽略。
1.2 生产代码中的使用方式
在你的业务组件中维护一个quartz.Clock引用,凡是原本调用time启动定时器/时钟的地方,一律改调这个clock:
import "github.com/coder/quartz" type Component struct { ... // for testing clock quartz.Clock }在生产初始化时,把它设置为quartz.NewReal()——从 vendor/github.com/coder/quartz/real.go 的源码可以看到,realClock的实现非常直白:NewTicker直接time.NewTicker(d),Now直接time.Now(),Since直接time.Since(t),透传语义没有任何魔法,且realClock通过var _ Clock = realClock{}编译期断言保证完整实现接口。
二、Mock 时钟:把时间握在测试手里
2.1 创建 Mock 与设置起始时间
测试中创建*Mock的方式极其简单:
import ( "testing" "github.com/coder/quartz" ) func TestComponent(t *testing.T) { mClock := quartz.NewMock(t) comp := &Component{ ... clock: mClock, } }从源码 vendor/github.com/coder/quartz/mock.go 可见,NewMock的默认起始时间是2024 年 1 月 1 日 00:00 UTC(内部用time.Parse(time.RFC3339, "2024-01-01T00:00:00Z")解析),并在t.Cleanup中注册清理逻辑,测试结束后Mock将不再记录时钟事件。
你也可以在测试开始前设置任意起始时间:
mClock := quartz.NewMock(t) mClock.Set(time.Date(2021, 6, 18, 12, 0, 0, 0, time.UTC)) // June 18, 2021 @ 12pm UTC重要约束:一旦开始设置定时器或时钟,时间只能向前推进、不能回拨。虽然可以继续用Set(),但通常用Advance()更清晰。
2.2 Advance:推进时间并触发事件
以定时器为例:
fired := false tmr := mClock.AfterFunc(time.Second, func() { fired = true }) mClock.Advance(time.Second)调用Advance()会立即把时钟前移指定时长,并触发所有排定在那一刻的 ticker 与 timer。被触发的事件运行在独立 goroutine 上,因此不能立刻断言结果:
fired := false tmr := mClock.AfterFunc(time.Second, func() { fired = true }) mClock.Advance(time.Second) // RACE CONDITION, DO NOT DO THIS! if !fired { t.Fatal("didn't fire") }Advance()(以及Set())会返回一个AdvanceWaiter对象,用来等待所有被触发的事件执行完毕:
fired := false // set a test timeout so we don't wait the default `go test` timeout for a failure ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) tmr := mClock.AfterFunc(time.Second, func() { fired = true }) w := mClock.Advance(time.Second) err := w.Wait(ctx) if err != nil { t.Fatal("AfterFunc f never completed") } if !fired { t.Fatal("didn't fire") }从 mock.go 的Advance实现可以印证其机制:Advance先加锁计算目标时间fin := m.cur.Add(d),随后分三种情况处理——没有事件排定(m.nextTime.IsZero())或目标时间还没到下一个事件时直接同步推进;目标时间超过下一个事件时报错(cannot advance ... which is beyond next timer/ticker event);恰好落在下一个事件时把cur置为nextTime,然后异步在advanceLocked里为每个事件各起一个 goroutine 执行fire(t),用sync.WaitGroup等待全部完成后再关闭w.ch。注意advanceLocked会先释放锁再执行事件回调,这正是“事件回调中可以继续调用 Mock 查询时间或注册新定时器”而不死锁的关键(详见源码注释)。
2.3 MustWait 简写
“等待触发事件完成,若超时则直接让测试失败”是极其常见的模式,因此提供了一等简写:
w := mClock.Advance(time.Second) err := w.Wait(ctx) if err != nil { t.Fatal("AfterFunc f never completed") }等价于:
w := mClock.Advance(time.Second) w.MustWait(ctx)更简洁的链式写法:
mClock.Advance(time.Second).MustWait(ctx)MustWait的内部实现(mock.go)在 context 先于事件完成时直接调用tb.Fatalf,因此它必须在运行测试或 benchmark 的 goroutine 中调用(类似t.FailNow()的约束)。
2.4 只能推进到下一个事件
Advance有一个重要的设计限制:只能推进到下一个 timer/ticker 事件,不能越过它。下面的测试会失败:
func TestAdvanceTooFar(t *testing.T) { ctx, cancel := context.WithTimeout(10*time.Second) defer cancel() mClock := quartz.NewMock(t) var firedAt time.Time mClock.AfterFunc(time.Second, func() { firedAt := mClock.Now() }) mClock.Advance(2*time.Second).MustWait(ctx) }这是刻意的设计决策:它让Advance()可以不依赖返回的 waiter 就立即同步地移动时钟,从而满足 Quartz“确定性、易理解”的设计目标;同时,它允许你在 tick/timer 函数执行过程中确定性地继续推进时钟(这正是下一节 Traps 的用武之地)。
推进多个事件可以通过循环完成,例如对 1 秒周期的 ticker 推进 10 次:
for i := 0; i < 10; i++ { mClock.Advance(time.Second).MustWait(ctx) }2.5 AdvanceNext 与 Peek:不手算就推进
如果你不知道、也不想去计算到下一个事件的时间,可以用AdvanceNext():
d, w := mClock.AdvanceNext() w.MustWait(ctx) // d contains the duration we advanced从源码 mock.go 看,AdvanceNext在没有任何排定事件时会直接t.Error(“cannot AdvanceNext because there are no timers or tickers running”)并返回(0, waiter);否则计算d := m.nextTime.Sub(m.cur)并把时钟推到该事件。
Peek()则返回直到下一个事件的时间(d, ok := Peek(),ok为true表示确实有排定事件),常用于“推进任意指定时长,同时不越过任何事件”的场景:
desired := time.Minute // time to advance for desired > 0 { p, ok := mClock.Peek() if !ok || p > desired { mClock.Advance(desired).MustWait(ctx) break } mClock.Advance(p).MustWait(ctx) desired -= p }Peek的实现(mock.go)就是返回m.nextTime.Sub(m.cur),没有排定事件时返回(0, false)。
三、Trap:拦截、检查、放行一次时钟调用
3.1 为什么需要 Trap
当被测代码异步于测试代码执行时,单纯推进时钟就不够用了。Trap(陷阱)允许你在 Mock 模式下匹配特定的库调用、阻塞其返回、检查其参数、然后放行,从而写出确定性的测试。你需要在执行被测代码之前设好陷阱,然后等待它被触发。
func TestTrap(t *testing.T) { ctx, cancel := context.WithTimeout(10*time.Second) defer cancel() mClock := quartz.NewMock(t) trap := mClock.Trap().AfterFunc() defer trap.Close() // stop trapping AfterFunc calls count := 0 go mClock.AfterFunc(time.Hour, func(){ count++ }) call := trap.MustWait(ctx) call.MustRelease(ctx) if call.Duration != time.Hour { t.Fatal("wrong duration") } // Now that the async call to AfterFunc has occurred, we can advance the clock to trigger it mClock.Advance(call.Duration).MustWait(ctx) if count != 1 { t.Fatal("wrong count") } }这个测试里,陷阱承担了两个职责:其一,捕获并断言传给AfterFunc的 duration;其二,消除“设置定时器”与“推进时钟”之间的竞态——这两件事发生在不同 goroutine,如果Advance()在AfterFunc()被调用前就完成了,本测试中的定时器将永远不会触发。
未被陷阱匹配的调用会立即用当前时间完成;对陷阱调用Close()会让 Mock 停止拦截这些调用。Close()的实现(mock.go)还会在存在未释放调用时报告Closed() with %d unreleased calls,帮助你及早发现泄漏的陷阱调用。
3.2 陷阱与推进的配合:让调用“看到”被推进后的时间
你还可以在捕获调用之后、放行之前推进时钟,调用将以放行那一刻的(Mock)当前时间完成:
func TestTrap2(t *testing.T) { ctx, cancel := context.WithTimeout(10*time.Second) defer cancel() mClock := quartz.NewMock(t) trap := mClock.Trap().Now() defer trap.Close() // stop trapping AfterFunc calls var logs []string done := make(chan struct{}) go func(clk quartz.Clock){ defer close(done) start := clk.Now() phase1() p1end := clk.Now() logs = append(fmt.Sprintf("Phase 1 took %s", p1end.Sub(start).String())) phase2() p2end := clk.Now() logs = append(fmt.Sprintf("Phase 2 took %s", p2end.Sub(p1end).String())) }(mClock) // start trap.MustWait(ctx).MustRelease(ctx) // phase 1 call := trap.MustWait(ctx) mClock.Advance(3*time.Second).MustWait(ctx) call.MustRelease(ctx) // phase 2 call = trap.MustWait(ctx) mClock.Advance(5*time.Second).MustWait(ctx) call.MustRelease(ctx) <-done // Now logs contains []string{"Phase 1 took 3s", "Phase 2 took 5s"} }这个例子完美展示了“在两次Now()调用之间精确控制流逝时间”的能力:phase1的结束时间被推前 3 秒,phase2的结束时间被再推前 5 秒,最终日志精确得到Phase 1 took 3s与Phase 2 took 5s。
3.3 Trap 的底层机制
从 mock.go 的matchCallLocked可以看到匹配流程:每次时钟调用都会构造一个apiCall,遍历当前 Mock 上注册的所有陷阱,凡matches(c)命中的陷阱各自起一个 goroutine 通过t.catch(c)把调用投递到陷阱的calls通道;测试端通过trap.Wait(ctx)/trap.MustWait(ctx)取出Call,再MustRelease(ctx)放行。matches(mock.go)的判定条件是:函数类型相等,且陷阱声明的所有 tag 都出现在调用的 tags 中(用slices.Contains逐个检查)。
另外注意Call.Release的注释(mock.go):如果一次调用被多个陷阱同时捕获,所有陷阱都必须放行该调用,且必须从不同 goroutine 放行,调用才会真正完成——这是基于sync.WaitGroup计数实现的。
四、Tags:多 goroutine 场景下的精准匹配
当被测代码中有多个 goroutine 同时调用 Clock 时,可以用tags在陷阱中区分它们:
trap := mClock.Trap.Now("foo") // traps any calls that contain "foo" defer trap.Close() foo := make(chan time.Time) go func(){ foo <- mClock.Now("foo", "bar") }() baz := make(chan time.Time) go func(){ baz <- mClock.Now("baz") }() call := trap.MustWait(ctx) mClock.Advance(time.Second).MustWait(ctx) call.MustRelease(ctx) // call.Tags contains []string{"foo", "bar"} gotFoo := <-foo // 1s after start gotBaz := <-baz // ?? never trapped, so races with Advance()Tag 以可选的...string后缀出现在所有Clock方法上,也出现在返回的 timer/ticker 的所有方法上;真实时钟会完全忽略它们(见 real.go 中_ ...string的空接口参数)。示例中,陷阱只捕获含"foo"的调用,因此gotFoo精确收到推进 1 秒后的时间,而gotBaz未被拦截、与Advance()之间存在竞态——这正是 tags 帮你把“该管的不该管的”分清楚的价值。
4.1 推荐的 Tag 约定
官方建议用如下约定打 tag,这样当代码演进引入新组件或新方法时,不太容易破坏既有单元测试:
func (c *Component) Method() { now := c.clock.Now("Component", "Method") }或细分到阶段:
func (c *Component) Method() { start := c.clock.Now("Component", "Method", "start") ... end := c.clock.Now("Component", "Method", "end") }五、推荐实践模式
5.1 Option 模式注入测试时钟
为了保持生产环境的构造签名干净,官方推荐用 Option 模式注入 Mock 时钟,该模式与其他可选字段天然兼容:
type Option func(*Thing) // WithTestClock is used in tests to inject a mock Clock func WithTestClock(clk quartz.Clock) Option { return func(t *Thing) { t.clock = clk } } func NewThing(<required args>, opts ...Option) *Thing { t := &Thing{ ... clock: quartz.NewReal() } for _, o := range opts { o(t) } return t }测试中则是:
func TestThing(t *testing.T) { mClock := quartz.NewMock(t) thing := NewThing(<required args>, WithTestClock(mClock)) ... }这种模式的好处是:生产代码路径零测试痕迹,测试注入点清晰可寻。
5.2 Loki 中的真实落地
Quartz 并非只存在于 vendor 目录里的“文档库”,Loki 仓库中已有实际使用。以 pkg/engine/retention.go 为例,结构体retention持有一个clock quartz.Clock字段,并在构造函数中默认赋值为quartz.NewReal();同样地,pkg/limits/consumer.go 中也以clock quartz.Clock字段配合quartz.NewReal()初始化。对应测试文件(如 pkg/engine/retention_test.go、pkg/limits/consumer_test.go)则通过quartz.NewMock(t)在测试中注入可控时钟。这套“生产用NewReal、测试用NewMock”的组合拳,正是 Quartz 设计意图的样板实践。
六、为什么还要再造一个时间测试库?
写好依赖time包的组件测试历来困难,即便已有多个开源库,Quartz 仍认为它们不足以支撑自己的目标。Quartz 的灵感来自:
- github.com/benbjohnson/clock
- Tailscale 的 tstest.Clock
- github.com/aspenmesh/tock
Quartz 与它们共享高层设计:一个与time标准库函数高度相似的Clock接口;生产环境“真实时钟”透传标准库,测试环境“Mock 时钟”提供精确控制。但为了达成“执行快、不 flake、易读”的目标,Quartz 在两个核心痛点上做了更彻底的机制设计。
6.1 防止测试 flake:两个经典竞态
以下示例来自 benbjohnson/clock 的 README:
mock := clock.NewMock() count := 0 // Kick off a timer to increment every 1 mock second. go func() { ticker := mock.Ticker(1 * time.Second) for { <-ticker.C count++ } }() runtime.Gosched() // Move the clock forward 10 seconds. mock.Add(10 * time.Second) // This prints 10. fmt.Println(count)第一个竞态很明显:时钟前移 10 秒确实可能在ticker.C上产生 10 个 tick,但没有任何机制保证count++先于fmt.Println(count)执行。
第二个竞态更隐蔽,runtime.Gosched()就是线索:ticker 是在独立 goroutine上启动的,没有任何保证说mock.Ticker()一定先于mock.Add()执行。runtime.Gosched()只是在“尽力”促成这件事,但它不提供任何硬性承诺。在繁忙的机器上、尤其是并行跑测试时,很可能先推进了 10 秒、之后才启动 ticker,于是一个 tick 都产生不出来。
6.2 Quartz 的解法:TickerFunc + Waiter
Quartz 认为一个极常见的模式是“创建 ticker,然后在 2 臂select里同时监听 tick 与 context 取消”:
t := time.NewTicker(duration) for { select { case <-ctx.Done(): return ctx.Err() case <-t.C: err := do() if err != nil { return err } } }Quartz 把它重构为更紧凑、更便于测试的形式:
t := clock.TickerFunc(ctx, duration, do) return t.Wait()关键在于:TickerFunc把处理逻辑do()包进了传给它的函数里,Mock 时钟因此能够显式知道“一个 tick 何时处理完毕”。所以当你在 Quartz 中推进时钟时,拿到的 waiter 可以确保所有被触发的 tick 与 timer 都已结束——这就解决了前面第一个竞态。
补充说明:Quartz 依然支持标准库风格的Ticker。如果你的代码希望尽量贴近标准库,或需要在更大的select块里使用 channel,可以继续用它;但这时你就得另找机制来让测试代码与 tick 处理同步。
针对“ticker 启动”的竞态,Quartz 用陷阱(trap)来兜底:
func TestTicker(t *testing.T) { mClock := quartz.NewMock(t) trap := mClock.Trap().TickerFunc() defer trap.Close() // stop trapping at end go runMyTicker(mClock) // async calls TickerFunc() call := trap.MustWait(context.Background()) // waits for a call and blocks its return call.MustRelease(ctx) // allow the TickerFunc() call to return // optionally check the duration using call.Duration // Move the clock forward 1 tick mClock.Advance(time.Second).MustWait(context.Background()) // assert results of the tick }先捕获并放行TickerFunc()调用,保证 ticker 在确定性的时刻启动,这样后续Advance()的效果就是可预测的。完整可运行示例可参考仓库中的example_test.go(TestExampleTickerFunc)。
6.3 复杂时间依赖:测量耗时与“空闲超时”
另一个难点是:被测代码连续多次依赖时间的调用,而你希望在两次调用之间模拟时间流逝。最基本的例子是测量某件事花了多久:
var measurement time.Duration go func(clock quartz.Clock) { start := clock.Now() doSomething() measurement = clock.Since(start) }(mClock) // how to get measurement to be, say, 5 seconds?两次时钟调用是异步发生的,我们必须能在第一次Now()之后、Since()之前推进时钟。用其他库往往得先 mock 或阻塞doSomething()的完成。而用 Quartz 的陷阱可以确定性地控制每次调用看到的时间:
trap := mClock.Trap().Since() var measurement time.Duration go func(clock quartz.Clock) { start := clock.Now() doSomething() measurement = clock.Since(start) }(mClock) c := trap.MustWait(ctx) mClock.Advance(5*time.Second) c.MustRelease(ctx)我们等到clock.Since()被陷阱捕获(这隐含了clock.Now()已经完成),然后把 Mock 时钟推进 5 秒,最后放行clock.Since()。任何在放行之前发生的时钟变化都会被计入这次Since()的结果。
再举一个更复杂的例子:空闲超时——如果在一段时长(比如 10 分钟)内没有活动记录,就触发某个动作:
type InactivityTimer struct { mu sync.Mutex activity time.Time clock quartz.Clock } func (i *InactivityTimer) Start() { i.mu.Lock() defer i.mu.Unlock() next := i.clock.Until(i.activity.Add(10*time.Minute)) t := i.clock.AfterFunc(next, func() { i.mu.Lock() defer i.mu.Unlock() next := i.clock.Until(i.activity.Add(10*time.Minute)) if next == 0 { i.timeoutLocked() return } t.Reset(next) }) }timeoutLocked()的具体内容与本题无关,假定还有别的函数负责记录最新的activity。
Quartz 团队发现:有些时间测试库在调用传给AfterFunc的函数时会持有 Mock 时钟的锁,一旦函数内部再调用时钟就会死锁;另一些库虽然允许这种用法,却缺乏测试边界情况(edge case)的灵活性。上面的Start()其实藏着一个细微 bug:timer 可能晚一点点触发,或者AfterFunc内部调用Until()前流逝了可测量的真实时间——如果一直没有活动,next可能变成负数。
在 Quartz 中测试这个 bug,我们只需要陷阱内部那次Until()调用。为了让“只拦内层、不拦外层”更容易,可以给想拦的调用打 tag:
func (i *InactivityTimer) Start() { i.mu.Lock() defer i.mu.Unlock() next := i.clock.Until(i.activity.Add(10*time.Minute)) t := i.clock.AfterFunc(next, func() { i.mu.Lock() defer i.mu.Unlock() next := i.clock.Until(i.activity.Add(10*time.Minute), "inner") if next == 0 { i.timeoutLocked() return } t.Reset(next) }) }所有 QuartzClock函数,以及返回的 timer/ticker 上的函数,都支持零个或多个字符串 tag 供陷阱匹配。测试如下:
func TestInactivityTimer_Late(t *testing.T) { // set a timeout on the test itself, so that if Wait functions get blocked, we don't have to // wait for the default test timeout of 10 minutes. ctx, cancel := context.WithTimeout(10*time.Second) defer cancel() mClock := quartz.NewMock(t) trap := mClock.Trap.Until("inner") defer trap.Close() it := &InactivityTimer{ activity: mClock.Now(), clock: mClock, } it.Start() // Trigger the AfterFunc w := mClock.Advance(10*time.Minute) c := trap.MustWait(ctx) // Advance the clock a few ms to simulate a busy system mClock.Advance(3*time.Millisecond) c.MustRelease(ctx) // Until() returns w.MustWait(ctx) // Wait for the AfterFunc to wrap up // Assert that the timeoutLocked() function was called }这个测试用例会在我们有 bug 的实现上失败:被触发的AfterFunc不会调用timeoutLocked(),而是用一个负数去Reset定时器。修复方法很简单——把判断条件改成next <= 0。
七、小结
Quartz 用三层设计把“时间”变成了测试中完全可控的输入:
| 层次 | 机制 | 核心价值 |
|---|---|---|
| 抽象层 | Clock接口 +NewReal() | 生产代码零侵入,透传标准库 |
| 控制层 | NewMock(t)+Advance/Set/AdvanceNext/Peek | 时间只能单调前进,可精确推进到任意事件 |
| 高级层 | Trap+Tag | 拦截任意时钟调用、检查参数、按需放行,消除异步竞态 |
配合“Option 模式注入 + 组件/方法名打 tag”的推荐实践,Loki 已在 pkg/engine/retention.go 与 pkg/limits/consumer.go 等模块中验证了这套方案。如果你正在为依赖time的代码写测试而饱受Sleep/Gosched/轮询之苦,不妨把业务组件改造成依赖quartz.Clock,用 Mock 时钟把测试重新掌握在自己手中。
【免费下载链接】lokiLike Prometheus, but for logs.项目地址: https://gitcode.com/GitHub_Trending/lok/loki
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考