)
深入 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不 flakedont 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.goClock接口完整覆盖了日常最常用的时间原语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启动定时器/时钟的地方一律改调这个clockimport 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(didnt fire) }Advance()以及Set()会返回一个AdvanceWaiter对象用来等待所有被触发的事件执行完毕fired : false // set a test timeout so we dont 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(didnt 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)放行。matchesmock.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/clockTailscale 的 tstest.Clockgithub.com/aspenmesh/tockQuartz 与它们共享高层设计一个与time标准库函数高度相似的Clock接口生产环境“真实时钟”透传标准库测试环境“Mock 时钟”提供精确控制。但为了达成“执行快、不 flake、易读”的目标Quartz 在两个核心痛点上做了更彻底的机制设计。6.1 防止测试 flake两个经典竞态以下示例来自 benbjohnson/clock 的 READMEmock : 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 WaiterQuartz 认为一个极常见的模式是“创建 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.goTestExampleTickerFunc。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()其实藏着一个细微 bugtimer 可能晚一点点触发或者AfterFunc内部调用Until()前流逝了可测量的真实时间——如果一直没有活动next可能变成负数。在 Quartz 中测试这个 bug我们只需要陷阱内部那次Until()调用。为了让“只拦内层、不拦外层”更容易可以给想拦的调用打 tagfunc (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 dont 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时间只能单调前进可精确推进到任意事件高级层TrapTag拦截任意时钟调用、检查参数、按需放行消除异步竞态配合“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),仅供参考