Mocking Time — Specification¶
Table of Contents¶
- Scope
ClockInterface Contractclockwork.Clockandclockwork.FakeClockbenbjohnson/clock.Clockandclock.Mocktesting/synctestContract- Compatibility Matrix
- Failure Modes
Scope¶
This document specifies the contract a "clock" type must obey to be usable as a drop-in replacement for the standard library time calls. It also specifies the test-only API additions (Advance, BlockUntil, Run) that fake implementations expose.
The reference implementations are:
github.com/jonboulle/clockworkv0.4.0github.com/benbjohnson/clockv1.3.5testing/synctestin Go 1.24
Behavioural details that differ between these are explicitly called out.
Clock Interface Contract¶
A Clock is any type satisfying at minimum:
Most code requires more:
type Clock interface {
Now() time.Time
Sleep(d time.Duration)
Since(t time.Time) time.Duration
After(d time.Duration) <-chan time.Time
NewTimer(d time.Duration) Timer
NewTicker(d time.Duration) Ticker
AfterFunc(d time.Duration, f func()) Timer
}
type Timer interface {
Chan() <-chan time.Time
Stop() bool
Reset(d time.Duration) bool
}
type Ticker interface {
Chan() <-chan time.Time
Stop()
Reset(d time.Duration)
}
Behavioural requirements¶
Now()is non-blocking and side-effect-free.Sleep(d)returns no earlier thandof clock time after the call (not necessarily wall time).After(d)returns a<-chan time.Timethat receives one value, the time of receipt (in clock time), exactly once.NewTimer(d).Chan()behaves likeAfter(d), with the option toStoporReset.NewTicker(d).Chan()receives a value everydof clock time untilStopis called.AfterFunc(d, f)invokesfon some goroutine afterdof clock time has elapsed. The goroutine identity is implementation-defined.
Real-clock implementations¶
For a real implementation, "clock time" is wall time. The behaviour is identical to calling the corresponding time package function directly.
Fake-clock implementations¶
For a fake implementation, "clock time" is the implementation's internal now, which advances only when test code calls Advance (or, in synctest, when the runtime advances it during quiescence).
Monotonicity¶
- A real clock's
Now()is monotonic-strip-free: the returned value carries a monotonic component (Go spec). - Fake clocks typically do not carry a monotonic component;
Now()returns a wall-clock-only value. - Tests that depend on monotonic semantics should use a real clock or use
synctest.
clockwork.Clock and clockwork.FakeClock¶
Interface¶
type Clock interface {
After(d time.Duration) <-chan time.Time
Sleep(d time.Duration)
Now() time.Time
Since(t time.Time) time.Duration
NewTicker(d time.Duration) Ticker
NewTimer(d time.Duration) Timer
AfterFunc(d time.Duration, f func()) Timer
}
type FakeClock interface {
Clock
Advance(d time.Duration)
BlockUntil(n int)
BlockUntilContext(ctx context.Context, n int) error
}
Constructors¶
NewRealClock() ClockNewFakeClock() FakeClock— starts attime.Now()(captured once at construction).NewFakeClockAt(t time.Time) FakeClock— starts att.
Advance(d)¶
dmay be positive, zero, or negative.- Fires every sleeper with
deadline.After(now)false (i.e.,deadline ≤ now). - Fire order is implementation-defined; current implementation is registration order, filtered by deadline.
BlockUntil(n)¶
- Blocks the calling goroutine until at least
nsleepers (fromAfter,NewTimer,NewTicker,AfterFunc,Sleep) are currently registered. - Returns immediately if
nsleepers are already registered when called. - "Sleeper" includes goroutines currently blocked in
Sleep.
BlockUntilContext(ctx, n)¶
- Like
BlockUntilbut cancellable. Returnsctx.Err()if the context cancels first.
Guarantees¶
Advanceis atomic from the perspective of subsequentNow()calls: afterAdvancereturns,Now()reflects the new time.Advanceis not atomic from the perspective of fired sleepers: a goroutine awoken byAdvancemay run beforeAdvancereturns to its caller.
benbjohnson/clock.Clock and clock.Mock¶
Interface¶
type Clock interface {
After(d time.Duration) <-chan time.Time
AfterFunc(d time.Duration, f func()) *Timer
Now() time.Time
Since(t time.Time) time.Duration
Sleep(d time.Duration)
Tick(d time.Duration) <-chan time.Time
Ticker(d time.Duration) *Ticker
Timer(d time.Duration) *Timer
}
Timer and Ticker are concrete types, not interfaces.
Constructors¶
New() Clock— real clockNewMock() *Mock— fake clock starting at Unix epoch
Mock extras¶
Add(d)advances time byd. Fires any pending wakeups.Set(t)jumps to absolute timet.
Notes¶
- No
BlockUntilequivalent in v1.3. Workarounds: usetime.Sleep(microsecond)between arming andAdd(ugly), or use a fork. Tickreturns a leak-prone channel liketime.Tick. PreferTicker.
testing/synctest Contract¶
API¶
Semantics¶
Run(f)startsfin a new goroutine inside an isolated bubble.- All goroutines spawned by
f(transitively) are in the same bubble. - Within the bubble,
time.Now,time.Sleep,time.After,time.NewTimer,time.NewTicker,time.AfterFunc, andtime.Tickconsult a per-bubble fake clock. - The runtime advances the bubble's clock when every goroutine in the bubble is durably blocked.
Wait()blocks until the bubble is in a durably-blocked state.
Durably blocked¶
A goroutine is durably blocked when:
- It is blocked on a channel inside the bubble.
- It is blocked on a
syncprimitive inside the bubble. - It is blocked on
time.Sleepor a timer. - It is blocked on
runtime.Gosched().
A goroutine is not durably blocked when:
- It is performing I/O (network, file, syscall).
- It is blocked on a channel with a sender outside the bubble.
- It is in cgo.
Time advancement¶
- When all goroutines are durably blocked, the runtime advances the bubble's clock to the earliest pending timer deadline.
- Time may not advance past a real-time deadline imposed externally (e.g., a test timeout).
Restrictions¶
Runmay not be called recursively.- Goroutines escape the bubble if they outlive
Run. Standard practice: every goroutine inRunis joined beforeRunreturns. - The bubble is not GC-visible from outside; references in the bubble keep objects alive normally.
Compatibility¶
- Available from Go 1.24.0.
- The package is in the standard library, no module dependency required.
Compatibility Matrix¶
| Need | clockwork | benbjohnson/clock | synctest |
|---|---|---|---|
| Go 1.21+ | yes | yes | no |
| Go 1.24+ | yes | yes | yes |
BlockUntil | yes | no (workaround) | implicit via Wait |
| Context-aware block | yes (BlockUntilContext) | no | Wait returns when quiescent |
| Negative time | yes | yes | no |
| Multiple concurrent fakes | yes | yes | yes (per Run) |
| Production code untouched | no | no | yes |
| Real I/O inside test | yes | yes | breaks bubble |
Exact Advance(d) | yes | yes (Add(d)) | no |
| Step to next wakeup automatically | no | no | yes |
Failure Modes¶
Advance races arming¶
A test calls Advance before production code calls After. Result: Advance fires nothing; production goroutine waits forever.
Mitigation: BlockUntil(n) before Advance, or synctest.Wait.
BlockUntil(n) counts wrong¶
The production code arms more or fewer sleepers than expected (e.g., a background ticker contributes a sleeper). BlockUntil(1) returns immediately for the ticker, before the actual code arms.
Mitigation: know your sleeper count; consider synctest which avoids the counting.
Goroutine escapes the bubble¶
In synctest.Run, a goroutine started before Run or via a non-bubble path is on real time.
Mitigation: spawn all goroutines inside Run. Use Wait to ensure none escape.
Real I/O inside a synctest bubble¶
A net.Dial call is real-time. The bubble cannot advance time while a goroutine is blocked on it. Test hangs or times out.
Mitigation: isolate I/O behind an interface and provide an in-memory fake for tests under synctest.
Monotonic time mismatch¶
Production code uses time.Since (which uses monotonic). Fake clock returns wall-only time.Time. Since falls back to wall computation; usually correct, but Round(0) strips monotonic and changes equality semantics.
Mitigation: within fake clocks, prefer clock.Since(t) over time.Since(t).
AfterFunc callback on a foreign goroutine¶
fakeClock.AfterFunc runs the callback on a goroutine the fake controls. The test goroutine must synchronise (e.g., via channel) before asserting.
Mitigation: always synchronise on a channel; never on a bool.
Negative Advance does not un-fire sleepers¶
Once a sleeper has fired, calling Advance(-d) does not un-receive the value. This is a non-reversibility; tests cannot rewind state.
Mitigation: construct a fresh FakeClock per test when state matters.