quartz
packagemoduleThis package is not in the latest version of its module.
Details
Validgo.mod file
The Go module system was introduced in Go 1.11 and is the official dependency management solution for Go.
Redistributable license
Redistributable licenses place minimal restrictions on how software can be used, modified, and redistributed.
Tagged version
Modules with tagged versions give importers more predictable builds.
Stable version
When a project reaches major version v1 it is considered stable.
- Learn more about best practices
Repository
Links
README¶
Quartz
A Go time testing library for writing deterministic unit tests
Our high level goal is to write unit tests that
- execute quickly
- don't flake
- are straightforward to write and understand
For tests to execute quickly without flakes, we want to focus ondeterminism: the test should runthe same each time, and it should be easy to force the system into a known state (no races) beforeexecuting test assertions.time.Sleep
,runtime.Gosched()
, andpolling/Eventually are allsymptoms of an inability to do this easily.
Usage
Clock
interface
In your application code, maintain a reference to aquartz.Clock
instance to start timers andtickers, instead of the baretime
standard library.
import "github.com/coder/quartz"type Component struct {...// for testingclock quartz.Clock}
Whenever you would call intotime
to start a timer or ticker, callComponent
'sclock
instead.
In production, set this clock toquartz.NewReal()
to create a clock that just transparently passesthrough to the standardtime
library.
Mocking
In your tests, you can use a*Mock
to control the tickers and timers your code under test gets.
import ("testing""github.com/coder/quartz")func TestComponent(t *testing.T) {mClock := quartz.NewMock(t)comp := &Component{...clock: mClock,}}
The*Mock
clock starts at Jan 1, 2024, 00:00 UTC by default, but you can set any start time you'd like prior to your test.
mClock := quartz.NewMock(t)mClock.Set(time.Date(2021, 6, 18, 12, 0, 0, 0, time.UTC)) // June 18, 2021 @ 12pm UTC
Advancing the clock
Once you begin setting timers or tickers, you cannot change the time backward, only advance itforward. You may continue to useSet()
, but it is often easier and clearer to useAdvance()
.
For example, with a timer:
fired := falsetmr := mClock.AfterFunc(time.Second, func() { fired = true})mClock.Advance(time.Second)
When you callAdvance()
it immediately moves the clock forward the given amount, and triggers anytickers or timers that are scheduled to happen at that time. Any triggered events happen on separategoroutines, sodo not immediately assert the results:
fired := falsetmr := 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()
(andSet()
for that matter) return anAdvanceWaiter
object you can use to wait forall triggered events to complete.
fired := false// set a test timeout so we don't wait the default `go test` timeout for a failurectx, 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")}
The construction of waiting for the triggered events and failing the test if they don't complete isvery common, so there is a shorthand:
w := mClock.Advance(time.Second)err := w.Wait(ctx)if err != nil { t.Fatal("AfterFunc f never completed")}
is equivalent to:
w := mClock.Advance(time.Second)w.MustWait(ctx)
or even more briefly:
mClock.Advance(time.Second).MustWait(ctx)
Advance only to the next event
One important restriction on advancing the clock is that you may only advance forward to the nexttimer or ticker event and no further. The following will result in a test failure:
func TestAdvanceTooFar(t *testing.T) {ctx, cancel := context.WithTimeout(10*time.Second)defer cancel()mClock := quartz.NewMock(t)var firedAt time.TimemClock.AfterFunc(time.Second, func() {firedAt := mClock.Now()})mClock.Advance(2*time.Second).MustWait(ctx)}
This is a deliberate design decision to allowAdvance()
to immediately and synchronously move theclock forward (even without callingWait()
on returned waiter). This helps meet Quartz's designgoals of writing deterministic and easy to understand unit tests. It also allows the clock to beadvanced, deterministicallyduring the execution of a tick or timer function, as explained in thenext sections on Traps.
Advancing multiple events can be accomplished via looping. E.g. if you have a 1-second ticker
for i := 0; i < 10; i++ {mClock.Advance(time.Second).MustWait(ctx)}
will advance 10 ticks.
If you don't know or don't want to compute the time to the next event, you can useAdvanceNext()
.
d, w := mClock.AdvanceNext()w.MustWait(ctx)// d contains the duration we advanced
d, ok := Peek()
returns the duration until the next event, if any (ok
istrue
). You can usethis to advance a specific time, regardless of the tickers and timer events:
desired := time.Minute // time to advancefor desired > 0 {p, ok := mClock.Peek()if !ok || p > desired {mClock.Advance(desired).MustWait(ctx)break}mClock.Advance(p).MustWait(ctx)desired -= p}
Traps
A trap allows you to match specific calls into the library while mocking, block their return,inspect their arguments, then release them to allow them to return. They help you writedeterministic unit tests even when the code under test executes asynchronously from the test.
You set your traps prior to executing code under test, and then wait for them to be triggered.
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 callscount := 0go 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 itmClock.Advance(call.Duration).MustWait(ctx)if count != 1 {t.Fatal("wrong count")}}
In this test, the trap serves 2 purposes. Firstly, it allows us to capture and assert the durationpassed to theAfterFunc
call. Secondly, it prevents a race between setting the timer and advancingit. Since these things happen on different goroutines, ifAdvance()
completes beforeAfterFunc()
is called, then the timer never pops in this test.
Any untrapped calls immediately complete using the current time, and callingClose()
on a trapcauses the mock clock to stop trapping those calls.
You may alsoAdvance()
the clock between trapping a call and releasing it. The call uses thecurrent (mocked) time at the moment it is released.
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 callsvar logs []stringdone := 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)// starttrap.MustWait(ctx).MustRelease(ctx)// phase 1call := trap.MustWait(ctx)mClock.Advance(3*time.Second).MustWait(ctx)call.MustRelease(ctx)// phase 2call = 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"}}
Tags
When multiple goroutines in the code under test call into the Clock, you can usetags
todistinguish them in your traps.
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 startgotBaz := <-baz // ?? never trapped, so races with Advance()
Tags appear as an optional suffix on allClock
methods (type...string
) and are ignored entirelyby the real clock. They also appear on all methods on returned timers and tickers.
Recommended Patterns
Options
We use the Option pattern to inject the mock clock for testing, keeping the call signature inproduction clean. The option pattern is compatible with other optional fields as well.
type Option func(*Thing)// WithTestClock is used in tests to inject a mock Clockfunc 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}
In tests, this becomes
func TestThing(t *testing.T) {mClock := quartz.NewMock(t)thing := NewThing(<required args>, WithTestClock(mClock))...}
Tagging convention
Tag yourClock
method calls as:
func (c *Component) Method() {now := c.clock.Now("Component", "Method")}
or
func (c *Component) Method() {start := c.clock.Now("Component", "Method", "start")...end := c.clock.Now("Component", "Method", "end")}
This makes it much less likely that code changes that introduce new components or methods will spoilexisting unit tests.
Why another time testing library?
Writing good unit tests for components and functions that use thetime
package is difficult, eventhough several open source libraries exist. In building Quartz, we took some inspiration from
Quartz shares the high level design of aClock
interface that closely resembles the functions inthetime
standard library, and a "real" clock passes thru to the standard library in production,while a mock clock gives precise control in testing.
As mentioned in our introduction, our high level goal is to write unit tests that
- execute quickly
- don't flake
- are straightforward to write and understand
For several reasons, this is a tall order when it comes to code that depends on time, and we foundthe existing libraries insufficient for our goals.
Preventing test flakes
The following example comes from the README from benbjohnson/clock:
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.Ccount++}}()runtime.Gosched()// Move the clock forward 10 seconds.mock.Add(10 * time.Second)// This prints 10.fmt.Println(count)
The first race condition is fairly obvious: moving the clock forward 10 seconds may generate 10ticks on theticker.C
channel, but there is no guarantee thatcount++
executes beforefmt.Println(count)
.
The second race condition is more subtle, butruntime.Gosched()
is the tell. Since the tickeris started on a separate goroutine, there is no guarantee thatmock.Ticker()
executes beforemock.Add()
.runtime.Gosched()
is an attempt to get this to happen, but it makes no hardpromises. On a busy system, especially when running tests in parallel, this can flake, advance thetime 10 seconds first, then start the ticker and never generate a tick.
Let's talk about how Quartz tackles these problems.
In our experience, an extremely common use case is creating a ticker then doing a 2-armselect
with ticks in one and context expiring in another, i.e.
t := time.NewTicker(duration)for {select {case <-ctx.Done():return ctx.Err()case <-t.C:err := do()if err != nil {return err}}}
In Quartz, we refactor this to be more compact and testing friendly:
t := clock.TickerFunc(ctx, duration, do)return t.Wait()
This affords the mockClock
the ability to explicitly know when processing of a tick is finishedbecause it's wrapped in the function passed toTickerFunc
(do()
in this example).
In Quartz, when you advance the clock, you are returned an object you canWait()
on to ensure allticks and timers triggered are finished. This solves the first race condition in the example.
(As an aside, we still support a traditional standard library-styleTicker
. You may find it usefulif you want to keep your code as close as possible to the standard library, or if you need to usethe channel in a largerselect
block. In that case, you'll have to find some other mechanism tosync tick processing to your test code.)
To prevent race conditions related to the starting of the ticker, Quartz allows you to set "traps"for calls that access the clock.
func TestTicker(t *testing.T) {mClock := quartz.NewMock(t)trap := mClock.Trap().TickerFunc()defer trap.Close() // stop trapping at endgo runMyTicker(mClock) // async calls TickerFunc()call := trap.MustWait(context.Background()) // waits for a call and blocks its returncall.MustRelease(ctx) // allow the TickerFunc() call to return// optionally check the duration using call.Duration// Move the clock forward 1 tickmClock.Advance(time.Second).MustWait(context.Background())// assert results of the tick}
Trapping and then releasing the call toTickerFunc()
ensures the ticker is started at adeterministic time, so our calls toAdvance()
will have a predictable effect.
Take a look atTestExampleTickerFunc
inexample_test.go
for a complete worked example.
Complex time dependence
Another difficult issue to handle when unit testing is when some code under test makes multiplecalls that depend on the time, and you want to simulate some time passing between them.
A very basic example is measuring how long something took:
var measurement time.Durationgo func(clock quartz.Clock) {start := clock.Now()doSomething()measurement = clock.Since(start)}(mClock)// how to get measurement to be, say, 5 seconds?
The two calls into the clock happen asynchronously, so we need to be able to advance the clock afterthe first call toNow()
but before the call toSince()
. Doing this with the libraries wementioned above means that you have to be able to mock out or otherwise block the completion ofdoSomething()
.
But, with the trap functionality we mentioned in the previous section, you can deterministicallycontrol the time each call sees.
trap := mClock.Trap().Since()var measurement time.Durationgo 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)
We wait until we trap theclock.Since()
call, which implies thatclock.Now()
has completed, thenadvance the mock clock 5 seconds. Finally, we release theclock.Since()
call. Any changes to theclock that happenbefore we release the call will be included in the time used for theclock.Since()
call.
As a more involved example, consider an inactivity timeout: we want something to happen if there isno activity recorded for some period, say 10 minutes in the following example:
type InactivityTimer struct {mu sync.Mutexactivity time.Timeclock 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)})}
The actual contents oftimeoutLocked()
doesn't matter for this example, and assume there are otherfunctions that record the latestactivity
.
We found that some time testing libraries hold a lock on the mock clock while calling the functionpassed toAfterFunc
, resulting in a deadlock if you made clock calls from within.
Others allow this sort of thing, but don't have the flexibility to test edge cases. There is asubtle bug in ourStart()
function. The timer may pop a little late, and/or some measurable realtime may elapse beforeUntil()
gets called inside theAfterFunc
. If there hasn't been activity,next
might be negative.
To test this in Quartz, we'll use a trap. We only want to trap the innerUntil()
call, not theinitial one, so to make testing easier we can "tag" the call we want. Like this:
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)})}
All QuartzClock
functions, and functions on returned timers and tickers support zero or morestring tags that allow traps to match on them.
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 AfterFuncw := mClock.Advance(10*time.Minute)c := trap.MustWait(ctx)// Advance the clock a few ms to simulate a busy systemmClock.Advance(3*time.Millisecond)c.MustRelease(ctx) // Until() returnsw.MustWait(ctx) // Wait for the AfterFunc to wrap up// Assert that the timeoutLocked() function was called}
This test case will fail with our bugged implementation, since the triggered AfterFunc won't calltimeoutLocked()
and instead will reset the timer with a negative number. The fix is easy, usenext <= 0
as the comparison.
Documentation¶
Overview¶
Package quartz is a library for testing time related code. It exports an interface Clock thatmimics the standard library time package functions. In production, an implementation that callsthru to the standard library is used. In testing, a Mock clock is used to precisely control andintercept time functions.
Index¶
- Variables
- type AdvanceWaiter
- type Call
- type Clock
- type Mock
- func (m *Mock) Advance(d time.Duration) AdvanceWaiter
- func (m *Mock) AdvanceNext() (time.Duration, AdvanceWaiter)
- func (m *Mock) AfterFunc(d time.Duration, f func(), tags ...string) *Timer
- func (m *Mock) NewTicker(d time.Duration, tags ...string) *Ticker
- func (m *Mock) NewTimer(d time.Duration, tags ...string) *Timer
- func (m *Mock) Now(tags ...string) time.Time
- func (m *Mock) Peek() (d time.Duration, ok bool)
- func (m *Mock) Set(t time.Time) AdvanceWaiter
- func (m *Mock) Since(t time.Time, tags ...string) time.Duration
- func (m *Mock) TickerFunc(ctx context.Context, d time.Duration, f func() error, tags ...string) Waiter
- func (m *Mock) Trap() Trapper
- func (m *Mock) Until(t time.Time, tags ...string) time.Duration
- type Ticker
- type Timer
- type Trap
- type Trapper
- func (t Trapper) AfterFunc(tags ...string) *Trap
- func (t Trapper) NewTicker(tags ...string) *Trap
- func (t Trapper) NewTimer(tags ...string) *Trap
- func (t Trapper) Now(tags ...string) *Trap
- func (t Trapper) Since(tags ...string) *Trap
- func (t Trapper) TickerFunc(tags ...string) *Trap
- func (t Trapper) TickerFuncWait(tags ...string) *Trap
- func (t Trapper) TickerReset(tags ...string) *Trap
- func (t Trapper) TickerStop(tags ...string) *Trap
- func (t Trapper) TimerReset(tags ...string) *Trap
- func (t Trapper) TimerStop(tags ...string) *Trap
- func (t Trapper) Until(tags ...string) *Trap
- type Waiter
Constants¶
This section is empty.
Variables¶
var ErrTrapClosed =errors.New("trap closed")
Functions¶
This section is empty.
Types¶
typeAdvanceWaiter¶
type AdvanceWaiter struct {// contains filtered or unexported fields}
AdvanceWaiter is returned from Advance and Set calls and allows you to wait for ticks and timersto complete. In the case of functions passed to AfterFunc or TickerFunc, it waits for thefunctions to return. For other ticks & timers, it just waits for the tick to be delivered tothe channel.
If multiple timers or tickers trigger simultaneously, they are all run on separatego routines.
func (AdvanceWaiter)Done¶
func (wAdvanceWaiter) Done() <-chan struct{}
Done returns a channel that is closed when all timers and ticks complete.
func (AdvanceWaiter)MustWait¶
func (wAdvanceWaiter) MustWait(ctxcontext.Context)
MustWait waits for all timers and ticks to complete, and fails the test immediately if thecontext completes first. MustWait must be called from the goroutine running the test orbenchmark, similar to `t.FailNow()`.
typeCall¶
type Call struct {Timetime.TimeDurationtime.DurationTags []string// contains filtered or unexported fields}
Call represents an apiCall that has been trapped.
func (*Call)MustRelease¶added inv0.2.0
MustRelease releases the call and waits for it to complete. If the provided context expires before the callcompletes, it fails the test.
IMPORTANT: If a call is trapped by more than one trap, they all must release the call before it can complete, andthey must do so from different goroutines.
typeClock¶
type Clock interface {// NewTicker returns a new Ticker containing a channel that will send the current time on the// channel after each tick. The period of the ticks is specified by the duration argument. The// ticker will adjust the time interval or drop ticks to make up for slow receivers. The// duration d must be greater than zero; if not, NewTicker will panic. Stop the ticker to// release associated resources.NewTicker(dtime.Duration, tags ...string) *Ticker// TickerFunc is a convenience function that calls f on the interval d until either the given// context expires or f returns an error. Callers may call Wait() on the returned Waiter to// wait until this happens and obtain the error. The duration d must be greater than zero; if// not, TickerFunc will panic.TickerFunc(ctxcontext.Context, dtime.Duration, f func()error, tags ...string)Waiter// NewTimer creates a new Timer that will send the current time on its channel after at least// duration d.NewTimer(dtime.Duration, tags ...string) *Timer// AfterFunc waits for the duration to elapse and then calls f in its own goroutine. It returns// a Timer that can be used to cancel the call using its Stop method. The returned Timer's C// field is not used and will be nil.AfterFunc(dtime.Duration, f func(), tags ...string) *Timer// Now returns the current local time.Now(tags ...string)time.Time// Since returns the time elapsed since t. It is shorthand for Clock.Now().Sub(t).Since(ttime.Time, tags ...string)time.Duration// Until returns the duration until t. It is shorthand for t.Sub(Clock.Now()).Until(ttime.Time, tags ...string)time.Duration}
typeMock¶
type Mock struct {// contains filtered or unexported fields}
Mock is the testing implementation of Clock. It tracks a time that monotonically increasesduring a test, triggering any timers or tickers automatically.
funcNewMock¶
NewMock creates a new Mock with the time set to midnight UTC on Jan 1, 2024.You may re-set the time earlier than this, but only before timers or tickersare created.
func (*Mock)Advance¶
func (m *Mock) Advance(dtime.Duration)AdvanceWaiter
Advance moves the clock forward by d, triggering any timers or tickers. The returned value canbe used to wait for all timers and ticks to complete. Advance sets the clock forward beforereturning, and can only advance up to the next timer or tick event. It will fail the test if youattempt to advance beyond.
If you need to advance exactly to the next event, and don't know or don't wish to calculate it,consider AdvanceNext().
func (*Mock)AdvanceNext¶
func (m *Mock) AdvanceNext() (time.Duration,AdvanceWaiter)
AdvanceNext advances the clock to the next timer or tick event. It fails the test if there arenone scheduled. It returns the duration the clock was advanced and a waiter that can be used towait for the timer/tick event(s) to finish.
func (*Mock)Peek¶
Peek returns the duration until the next ticker or timer event and the valuetrue, or, if there are no running tickers or timers, it returns zero andfalse.
func (*Mock)Set¶
func (m *Mock) Set(ttime.Time)AdvanceWaiter
Set the time to t. If the time is after the current mocked time, then this is equivalent toAdvance() with the difference. You may only Set the time earlier than the current time beforestarting tickers and timers (e.g. at the start of your test case).
func (*Mock)TickerFunc¶
typeTicker¶
A Ticker holds a channel that delivers “ticks” of a clock at intervals.
typeTimer¶
The Timer type represents a single event. When the Timer expires, the current time will be senton C, unless the Timer was created by AfterFunc. A Timer must be created with NewTimer orAfterFunc.
func (*Timer)Reset¶
Reset changes the timer to expire after duration d. It returns true if the timer had been active,false if the timer had expired or been stopped.
Seehttps://pkg.go.dev/time#Timer.Reset for more information.
func (*Timer)Stop¶
Stop prevents the Timer from firing. It returns true if the call stops the timer, false if thetimer has already expired or been stopped. Stop does not close the channel, to prevent a readfrom the channel succeeding incorrectly.
Seehttps://pkg.go.dev/time#Timer.Stop for more information.
typeTrap¶
type Trap struct {// contains filtered or unexported fields}
typeTrapper¶
type Trapper struct {// contains filtered or unexported fields}
Trapper allows the creation of Traps