From aa66432afcb0d7d91069fa4b9a1004a59953087f Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:25:31 +0000 Subject: [PATCH 01/16] fix: name the events that block Advance and Set After code moves from context.WithTimeout to Clock.WithTimeout, a pending mock context stops Advance and Set at its deadline, but the failure said "beyond next timer/ticker event", which points away from the context. AdvanceNext's failure also listed only timers and tickers. The failures now list each event due at the next event time by kind and by the call that created it, for example "mock context from WithTimeout(..., [a])". The call is printed the way Trap.String prints it, so the function and tags match the code under test and Trap(). Each event keeps the function and tags of its creating apiCall, which costs one field per event type and leaves scheduling unchanged. Rewording the message to mention contexts would give the kind but not which call, which matters when a test has several. The description leaves out the duration because Timer.Reset and Ticker.Reset change it after creation, and the message already says when the event is due. --- mock.go | 54 +++++++++++++++++++--- mock_test.go | 125 ++++++++++++++++++++++++++++++++++++++++++++++++++- ticker.go | 8 +++- timer.go | 5 +++ 4 files changed, 182 insertions(+), 10 deletions(-) diff --git a/mock.go b/mock.go index 5e9a4bb..af9cd7a 100644 --- a/mock.go +++ b/mock.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "slices" + "strings" "sync" "time" ) @@ -50,6 +51,20 @@ type Mock struct { type event interface { next() time.Time fire(t time.Time) + // describe names the event for failure messages. + describe() string +} + +// eventSource is the Clock call that created an event. +type eventSource struct { + fn clockFunction + tags []string +} + +// describe formats the call like Trap.String, so the user can find the call or trap it by function +// and tags. +func (s eventSource) describe(kind string) string { + return fmt.Sprintf("%s from %s(..., %v)", kind, s.fn, s.tags) } func (m *Mock) TickerFunc(ctx context.Context, d time.Duration, f func() error, tags ...string) Waiter { @@ -68,6 +83,7 @@ func (m *Mock) TickerFunc(ctx context.Context, d time.Duration, f func() error, nxt: m.cur.Add(d), mock: m, cond: sync.NewCond(&m.mu), + src: eventSource{fn: c.fn, tags: c.Tags}, } m.all = append(m.all, t) m.recomputeNextLocked() @@ -87,7 +103,7 @@ func (m *Mock) NewTicker(d time.Duration, tags ...string) *Ticker { c := newCall(clockFunctionNewTicker, tags, withDuration(d)) m.matchCallLocked(c) defer close(c.complete) - return newMockTickerLocked(m, d) + return newMockTickerLocked(m, d, eventSource{fn: c.fn, tags: c.Tags}) } func (m *Mock) NewTimer(d time.Duration, tags ...string) *Timer { @@ -102,6 +118,7 @@ func (m *Mock) NewTimer(d time.Duration, tags ...string) *Timer { c: ch, nxt: m.cur.Add(d), mock: m, + src: eventSource{fn: c.fn, tags: c.Tags}, } if d <= 0 { // zero or negative duration timer means we should immediately fire @@ -123,6 +140,7 @@ func (m *Mock) AfterFunc(d time.Duration, f func(), tags ...string) *Timer { nxt: m.cur.Add(d), fn: f, mock: m, + src: eventSource{fn: c.fn, tags: c.Tags}, } if d <= 0 { // zero or negative duration timer means we should immediately fire @@ -199,6 +217,7 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de finished: make(chan struct{}), cause: cause, mock: m, + src: eventSource{fn: call.fn, tags: call.Tags}, done: make(chan struct{}), } @@ -300,6 +319,15 @@ func (m *Mock) recomputeNextLocked() { m.nextEvents = events } +// describeNextEventsLocked lists the events scheduled at nextTime for failure messages. +func (m *Mock) describeNextEventsLocked() string { + descs := make([]string, 0, len(m.nextEvents)) + for _, e := range m.nextEvents { + descs = append(descs, e.describe()) + } + return strings.Join(descs, ", ") +} + func (m *Mock) removeTimer(t *Timer) { m.mu.Lock() defer m.mu.Unlock() @@ -407,8 +435,8 @@ func (m *Mock) Advance(d time.Duration) AdvanceWaiter { return w } if fin.After(m.nextTime) { - m.tb.Errorf("cannot advance %s which is beyond next timer/ticker event in %s", - d.String(), m.nextTime.Sub(m.cur)) + m.tb.Errorf("cannot advance %s which is beyond next event in %s: %s", + d.String(), m.nextTime.Sub(m.cur), m.describeNextEventsLocked()) m.mu.Unlock() close(w.ch) return w @@ -454,7 +482,8 @@ func (m *Mock) Set(t time.Time) AdvanceWaiter { defer m.mu.Unlock() // past if !m.nextTime.IsZero() { - m.tb.Error("Set mock clock to the past after timers/tickers started") + m.tb.Errorf("Set mock clock to the past while events are scheduled, next at %s: %s", + m.nextTime, m.describeNextEventsLocked()) } m.cur = t return w @@ -470,8 +499,8 @@ func (m *Mock) Set(t time.Time) AdvanceWaiter { if t.After(m.nextTime) { defer close(w.ch) defer m.mu.Unlock() - m.tb.Errorf("cannot Set time to %s which is beyond next timer/ticker event at %s", - t.String(), m.nextTime) + m.tb.Errorf("cannot Set time to %s which is beyond next event at %s: %s", + t.String(), m.nextTime, m.describeNextEventsLocked()) return w } @@ -494,7 +523,7 @@ func (m *Mock) AdvanceNext() (time.Duration, AdvanceWaiter) { if m.nextTime.IsZero() { defer close(w.ch) defer m.mu.Unlock() - m.tb.Error("cannot AdvanceNext because there are no timers or tickers running") + m.tb.Error("cannot AdvanceNext because no timers, tickers, or mock contexts are scheduled") return 0, w } d := m.nextTime.Sub(m.cur) @@ -659,12 +688,18 @@ type mockTickerFunc struct { done bool // err holds the error when the ticker exits err error + + src eventSource } func (m *mockTickerFunc) next() time.Time { return m.nxt } +func (m *mockTickerFunc) describe() string { + return m.src.describe("ticker") +} + func (m *mockTickerFunc) fire(_ time.Time) { m.mock.mu.Lock() if m.done { @@ -731,6 +766,7 @@ var _ Waiter = &mockTickerFunc{} type mockCtx struct { mock *Mock parent context.Context + src eventSource // inner stores the cancellation cause so context.Cause() can find it via Value(&cancelCtxKey). // Done() must NOT return inner.Done(): stdlib's parentCancelCtx fast-path would then attach @@ -908,6 +944,10 @@ func (c *mockCtx) next() time.Time { return c.deadline } +func (c *mockCtx) describe() string { + return c.src.describe("mock context") +} + func (c *mockCtx) fire(_ time.Time) { c.cancel(context.DeadlineExceeded, c.cause) // Advance().MustWait() waits for fire, so wait for mock descendants that other goroutines are diff --git a/mock_test.go b/mock_test.go index 37a3fe1..c912e79 100644 --- a/mock_test.go +++ b/mock_test.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "runtime/pprof" + "slices" "strings" "sync" "testing" @@ -1208,6 +1209,122 @@ func TestWithTimeout_Trap(t *testing.T) { } } +// The failures name the events that stop the clock, so that a pending mock context is not mistaken +// for a timer or ticker. +func TestMock_FailureNamesBlockingEvents(t *testing.T) { + t.Parallel() + + t.Run("Advance", func(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + schedule func(t *testing.T, mClock *quartz.Mock) + want string + }{ + {"WithTimeout", func(t *testing.T, mClock *quartz.Mock) { + _, cancel := mClock.WithTimeout(context.Background(), 30*time.Second, "a") + t.Cleanup(cancel) + }, "mock context from WithTimeout(..., [a])"}, + {"NewTimer", func(_ *testing.T, mClock *quartz.Mock) { + mClock.NewTimer(30*time.Second, "a") + }, "timer from NewTimer(..., [a])"}, + {"AfterFunc", func(_ *testing.T, mClock *quartz.Mock) { + mClock.AfterFunc(30*time.Second, func() {}, "a") + }, "timer from AfterFunc(..., [a])"}, + {"NewTicker", func(_ *testing.T, mClock *quartz.Mock) { + mClock.NewTicker(30*time.Second, "a") + }, "ticker from NewTicker(..., [a])"}, + {"TickerFunc", func(t *testing.T, mClock *quartz.Mock) { + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + mClock.TickerFunc(ctx, 30*time.Second, func() error { return nil }, "a") + }, "ticker from TickerFunc(..., [a])"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + tb := &captureFailTB{TB: t} + mClock := quartz.NewMock(tb) + tc.schedule(t, mClock) + + mClock.Advance(time.Hour) + want := []string{ + "cannot advance 1h0m0s which is beyond next event in 30s: " + tc.want, + } + if !slices.Equal(tb.errs, want) { + t.Fatalf("expected errors %q, got %q", want, tb.errs) + } + }) + } + }) + + t.Run("Set", func(t *testing.T) { + t.Parallel() + tb := &captureFailTB{TB: t} + mClock := quartz.NewMock(tb) + start := mClock.Now() + mClock.NewTimer(30 * time.Second) + _, cancel := mClock.WithDeadline(context.Background(), start.Add(30*time.Second), "a") + defer cancel() + + mClock.Set(start.Add(time.Hour)) + want := []string{"cannot Set time to 2024-01-01 01:00:00 +0000 UTC which is beyond next " + + "event at 2024-01-01 00:00:30 +0000 UTC: timer from NewTimer(..., []), " + + "mock context from WithDeadline(..., [a])"} + if !slices.Equal(tb.errs, want) { + t.Fatalf("expected errors %q, got %q", want, tb.errs) + } + }) + + t.Run("SetPast", func(t *testing.T) { + t.Parallel() + tb := &captureFailTB{TB: t} + mClock := quartz.NewMock(tb) + start := mClock.Now() + _, cancel := mClock.WithTimeout(context.Background(), 30*time.Second, "a") + defer cancel() + + mClock.Set(start.Add(-time.Hour)) + want := []string{"Set mock clock to the past while events are scheduled, next at " + + "2024-01-01 00:00:30 +0000 UTC: mock context from WithTimeout(..., [a])"} + if !slices.Equal(tb.errs, want) { + t.Fatalf("expected errors %q, got %q", want, tb.errs) + } + }) + + t.Run("AdvanceNext", func(t *testing.T) { + t.Parallel() + testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer testCancel() + tb := &captureFailTB{TB: t} + mClock := quartz.NewMock(tb) + ctx, cancel := mClock.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // A pending mock context is the next event. + d, w := mClock.AdvanceNext() + if err := w.Wait(testCtx); err != nil { + t.Fatalf("waiting for AdvanceNext: %v", err) + } + if d != 30*time.Second { + t.Fatalf("expected AdvanceNext to advance 30s, got %s", d) + } + if !errors.Is(ctx.Err(), context.DeadlineExceeded) { + t.Fatalf("expected DeadlineExceeded, got %v", ctx.Err()) + } + if len(tb.errs) != 0 { + t.Fatalf("expected no errors, got %q", tb.errs) + } + + mClock.AdvanceNext() + want := []string{ + "cannot AdvanceNext because no timers, tickers, or mock contexts are scheduled", + } + if !slices.Equal(tb.errs, want) { + t.Fatalf("expected errors %q, got %q", want, tb.errs) + } + }) +} + func Test_MultipleTraps(t *testing.T) { t.Parallel() testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -1332,18 +1449,22 @@ func Test_WithLogger(t *testing.T) { type captureFailTB struct { failed bool + // errs records Error and Errorf messages. + errs []string testing.TB } func (t *captureFailTB) Errorf(format string, args ...any) { t.Helper() t.Logf(format, args...) + t.errs = append(t.errs, fmt.Sprintf(format, args...)) t.failed = true } func (t *captureFailTB) Error(args ...any) { t.Helper() t.Log(args...) + t.errs = append(t.errs, fmt.Sprint(args...)) t.failed = true } @@ -1459,8 +1580,8 @@ func TestTimerReset_NegativeDuration(t *testing.T) { } // The timer's old 1h schedule must not remain in the mock's event list. - // If it does, Advance rejects with "cannot advance beyond next - // timer/ticker event". + // If it does, Advance rejects with "cannot advance 2h0m0s which is beyond + // next event". if d, ok := mClock.Peek(); ok { t.Fatalf("expected no pending events, got Peek()=%s", d) } diff --git a/ticker.go b/ticker.go index f4a4b06..6e3fa14 100644 --- a/ticker.go +++ b/ticker.go @@ -13,6 +13,7 @@ type Ticker struct { mock *Mock // mock clock, if set stopped bool // true if the ticker is not running internalTicks chan time.Time // used to deliver ticks to the runLoop goroutine + src eventSource // mock call that created the ticker // As of Go 1.23, ticker channels are unbuffered and guaranteed to block forever after a call to stop. // @@ -46,6 +47,10 @@ func (t *Ticker) next() time.Time { return t.nxt } +func (t *Ticker) describe() string { + return t.src.describe("ticker") +} + // Stop turns off a ticker. After Stop, no more ticks will be sent. Stop does // not close the channel, to prevent a concurrent goroutine reading from the // channel from seeing an erroneous "tick". @@ -126,7 +131,7 @@ func (t *Ticker) startRunLoopLocked() { go t.runLoop(interrupt) } -func newMockTickerLocked(m *Mock, d time.Duration) *Ticker { +func newMockTickerLocked(m *Mock, d time.Duration, src eventSource) *Ticker { // no buffer follows Go 1.23+ behavior ticks := make(chan time.Time) t := &Ticker{ @@ -136,6 +141,7 @@ func newMockTickerLocked(m *Mock, d time.Duration) *Ticker { nxt: m.cur.Add(d), mock: m, internalTicks: make(chan time.Time), + src: src, } m.addEventLocked(t) m.tb.Cleanup(func() { diff --git a/timer.go b/timer.go index 0f5f733..fe2fc13 100644 --- a/timer.go +++ b/timer.go @@ -16,6 +16,7 @@ type Timer struct { mock *Mock // mock clock, if set fn func() // AfterFunc function, if set stopped bool // True if stopped, false if running + src eventSource // mock call that created the timer // As of Go 1.23, timer channels are unbuffered and guaranteed to block forever after a call to stop. // @@ -60,6 +61,10 @@ func (t *Timer) next() time.Time { return t.nxt } +func (t *Timer) describe() string { + return t.src.describe("timer") +} + // Stop prevents the Timer from firing. It returns true if the call stops the timer, false if the // timer has already expired or been stopped. Stop does not close the channel, to prevent a read // from the channel succeeding incorrectly. From c4102341942c2068fc3273ee182d98d823d6ec37 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:25:46 +0000 Subject: [PATCH 02/16] docs: list mock contexts among scheduled events The README and the Advance, Set, AdvanceNext, Peek, and NewMock godoc described the limits on moving the clock in terms of timers and tickers only. A pending context from Clock.WithTimeout or WithDeadline imposes the same limits, and a reader moving code off context.WithTimeout had no way to learn that from the docs. The Mock godoc now says which things are events, and the methods refer to events. The README Contexts section links to the Advance limit. --- README.md | 21 +++++++++++++-------- mock.go | 31 ++++++++++++++++--------------- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 1f9dc0f..2744627 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,9 @@ mClock.Set(time.Date(2021, 6, 18, 12, 0, 0, 0, time.UTC)) // June 18, 2021 @ 12p #### Advancing the clock -Once you begin setting timers or tickers, you cannot change the time backward, only advance it -forward. You may continue to use `Set()`, but it is often easier and clearer to use `Advance()`. +Once you begin setting timers, tickers, or [mock contexts](#contexts), you cannot change the time +backward, only advance it forward. You may continue to use `Set()`, but it is often easier and +clearer to use `Advance()`. For example, with a timer: @@ -80,8 +81,8 @@ mClock.Advance(time.Second) ``` When you call `Advance()` it immediately moves the clock forward the given amount, and triggers any -tickers or timers that are scheduled to happen at that time. Any triggered events happen on separate -goroutines, so _do not_ immediately assert the results: +tickers, timers, or mock contexts that are scheduled to happen at that time. Any triggered events +happen on separate goroutines, so _do not_ immediately assert the results: ```go fired := false @@ -146,7 +147,8 @@ 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 next -timer or ticker event and no further. The following will result in a test failure: +event and no further. Timers, tickers, and pending [mock contexts](#contexts) are events. The +following will result in a test failure: ```go func TestAdvanceTooFar(t *testing.T) { @@ -186,7 +188,7 @@ w.MustWait(ctx) ``` `d, ok := Peek()` returns the duration until the next event, if any (`ok` is `true`). You can use -this to advance a specific time, regardless of the tickers and timer events: +this to advance a specific time, regardless of the scheduled events: ```go desired := time.Minute // time to advance @@ -315,8 +317,8 @@ by the real clock. They also appear on all methods on returned timers and ticker ### Contexts `Clock` provides `WithDeadline`, `WithDeadlineCause`, `WithTimeout`, and `WithTimeoutCause` methods -that mirror the standard `context` package. With a `*Mock`, contexts created through the clock -expire on mock time: +that mirror the standard `context` package. With a `*Mock`, contexts created through the clock, +called mock contexts, expire on mock time: ```go ctx, cancel := mClock.WithTimeout(context.Background(), 30*time.Second) @@ -367,6 +369,9 @@ contexts created after the test ended are returned already canceled with that ca Call the returned cancel function as you would for `context.WithTimeout`. It is idempotent and removes the pending mock event. Cause variants preserve the cause for `context.Cause`. +A pending mock context also limits how far `Advance()` can move the clock; see +[Advance only to the next event](#advance-only-to-the-next-event). + #### `Deadline()` does not report mock time With a `*Mock`, `ctx.Done()` and `ctx.Err()` follow mock time, but `ctx.Deadline()` reports the diff --git a/mock.go b/mock.go index af9cd7a..8597154 100644 --- a/mock.go +++ b/mock.go @@ -32,7 +32,9 @@ type TestingT interface { var ErrMockClockTestEnded = errors.New("quartz: mock clock test ended") // Mock is the testing implementation of Clock. It tracks a time that monotonically increases -// during a test, triggering any timers or tickers automatically. +// during a test, triggering scheduled events automatically. Timers, tickers, and mock contexts are +// events. A mock context is a context from WithDeadline, WithTimeout, or their Cause variants, and +// it is scheduled until it expires or is canceled. type Mock struct { tb TestingT logger Logger @@ -411,10 +413,10 @@ func (w AdvanceWaiter) Done() <-chan struct{} { return w.ch } -// Advance moves the clock forward by d, triggering any timers or tickers. The returned value can -// be used to wait for all timers and ticks to complete. Advance sets the clock forward before -// returning, and can only advance up to the next timer or tick event. It will fail the test if you -// attempt to advance beyond. +// Advance moves the clock forward by d, triggering any events scheduled at that time. The returned +// value can be used to wait for the triggered events to complete. Advance sets the clock forward +// before returning, and can only advance up to the next scheduled event. It will fail the test if +// you attempt 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(). @@ -467,8 +469,8 @@ func (m *Mock) advanceLocked(w AdvanceWaiter) { } // Set the time to t. If the time is after the current mocked time, then this is equivalent to -// Advance() with the difference. You may only Set the time earlier than the current time before -// starting tickers and timers (e.g. at the start of your test case). +// Advance() with the difference. You may only Set the time earlier than the current time while +// no events are scheduled (e.g. at the start of your test case). func (m *Mock) Set(t time.Time) AdvanceWaiter { m.tb.Helper() w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})} @@ -509,9 +511,9 @@ func (m *Mock) Set(t time.Time) AdvanceWaiter { return w } -// AdvanceNext advances the clock to the next timer or tick event. It fails the test if there are -// none scheduled. It returns the duration the clock was advanced and a waiter that can be used to -// wait for the timer/tick event(s) to finish. +// AdvanceNext advances the clock to the next scheduled event. It fails the test if there are none +// scheduled. It returns the duration the clock was advanced and a waiter that can be used to wait +// for the event(s) to finish. func (m *Mock) AdvanceNext() (time.Duration, AdvanceWaiter) { m.checkDoneContexts() m.mu.Lock() @@ -532,9 +534,8 @@ func (m *Mock) AdvanceNext() (time.Duration, AdvanceWaiter) { return d, w } -// Peek returns the duration until the next ticker or timer event and the value -// true, or, if there are no running tickers or timers, it returns zero and -// false. +// Peek returns the duration until the next scheduled event and the value true, or, if there are +// no scheduled events, it returns zero and false. func (m *Mock) Peek() (d time.Duration, ok bool) { m.checkDoneContexts() m.mu.Lock() @@ -650,8 +651,8 @@ func (m *Mock) WithLogger(l Logger) *Mock { } // 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 tickers -// are created. +// You may re-set the time earlier than this, but only before any events are +// scheduled. func NewMock(tb TestingT) *Mock { cur, err := time.Parse(time.RFC3339, "2024-01-01T00:00:00Z") if err != nil { From bdd61a98a38da2db7544e88edf2930d8dfe7526c Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:36:32 +0000 Subject: [PATCH 03/16] fix: copy creation tags for failure messages eventSource kept the apiCall's tags, which alias the caller's slice when it calls with tags.... A caller that reused that slice after the call returned changed the tags that a later Advance or Set failure printed, and reusing it while another goroutine called Advance was a data race, because describe reads the tags long after the call. The Advance subtests now reuse the slice after each creation call and expect the original tags. --- mock.go | 16 +++++++++++----- mock_test.go | 28 ++++++++++++++++------------ 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/mock.go b/mock.go index 8597154..0081ade 100644 --- a/mock.go +++ b/mock.go @@ -69,6 +69,12 @@ func (s eventSource) describe(kind string) string { return fmt.Sprintf("%s from %s(..., %v)", kind, s.fn, s.tags) } +// newEventSource copies the tags, which may alias the caller's variadic slice. The caller may reuse +// that slice once the call returns, while describe reads the tags later. +func newEventSource(c *apiCall) eventSource { + return eventSource{fn: c.fn, tags: slices.Clone(c.Tags)} +} + func (m *Mock) TickerFunc(ctx context.Context, d time.Duration, f func() error, tags ...string) Waiter { if d <= 0 { panic("TickerFunc called with negative or zero duration") @@ -85,7 +91,7 @@ func (m *Mock) TickerFunc(ctx context.Context, d time.Duration, f func() error, nxt: m.cur.Add(d), mock: m, cond: sync.NewCond(&m.mu), - src: eventSource{fn: c.fn, tags: c.Tags}, + src: newEventSource(c), } m.all = append(m.all, t) m.recomputeNextLocked() @@ -105,7 +111,7 @@ func (m *Mock) NewTicker(d time.Duration, tags ...string) *Ticker { c := newCall(clockFunctionNewTicker, tags, withDuration(d)) m.matchCallLocked(c) defer close(c.complete) - return newMockTickerLocked(m, d, eventSource{fn: c.fn, tags: c.Tags}) + return newMockTickerLocked(m, d, newEventSource(c)) } func (m *Mock) NewTimer(d time.Duration, tags ...string) *Timer { @@ -120,7 +126,7 @@ func (m *Mock) NewTimer(d time.Duration, tags ...string) *Timer { c: ch, nxt: m.cur.Add(d), mock: m, - src: eventSource{fn: c.fn, tags: c.Tags}, + src: newEventSource(c), } if d <= 0 { // zero or negative duration timer means we should immediately fire @@ -142,7 +148,7 @@ func (m *Mock) AfterFunc(d time.Duration, f func(), tags ...string) *Timer { nxt: m.cur.Add(d), fn: f, mock: m, - src: eventSource{fn: c.fn, tags: c.Tags}, + src: newEventSource(c), } if d <= 0 { // zero or negative duration timer means we should immediately fire @@ -219,7 +225,7 @@ func (m *Mock) newMockCtx(parent context.Context, call *apiCall, cause error, de finished: make(chan struct{}), cause: cause, mock: m, - src: eventSource{fn: call.fn, tags: call.Tags}, + src: newEventSource(call), done: make(chan struct{}), } diff --git a/mock_test.go b/mock_test.go index c912e79..f539bbd 100644 --- a/mock_test.go +++ b/mock_test.go @@ -1218,33 +1218,37 @@ func TestMock_FailureNamesBlockingEvents(t *testing.T) { t.Parallel() for _, tc := range []struct { name string - schedule func(t *testing.T, mClock *quartz.Mock) + schedule func(t *testing.T, mClock *quartz.Mock, tags []string) want string }{ - {"WithTimeout", func(t *testing.T, mClock *quartz.Mock) { - _, cancel := mClock.WithTimeout(context.Background(), 30*time.Second, "a") + {"WithTimeout", func(t *testing.T, mClock *quartz.Mock, tags []string) { + _, cancel := mClock.WithTimeout(context.Background(), 30*time.Second, tags...) t.Cleanup(cancel) }, "mock context from WithTimeout(..., [a])"}, - {"NewTimer", func(_ *testing.T, mClock *quartz.Mock) { - mClock.NewTimer(30*time.Second, "a") + {"NewTimer", func(_ *testing.T, mClock *quartz.Mock, tags []string) { + mClock.NewTimer(30*time.Second, tags...) }, "timer from NewTimer(..., [a])"}, - {"AfterFunc", func(_ *testing.T, mClock *quartz.Mock) { - mClock.AfterFunc(30*time.Second, func() {}, "a") + {"AfterFunc", func(_ *testing.T, mClock *quartz.Mock, tags []string) { + mClock.AfterFunc(30*time.Second, func() {}, tags...) }, "timer from AfterFunc(..., [a])"}, - {"NewTicker", func(_ *testing.T, mClock *quartz.Mock) { - mClock.NewTicker(30*time.Second, "a") + {"NewTicker", func(_ *testing.T, mClock *quartz.Mock, tags []string) { + mClock.NewTicker(30*time.Second, tags...) }, "ticker from NewTicker(..., [a])"}, - {"TickerFunc", func(t *testing.T, mClock *quartz.Mock) { + {"TickerFunc", func(t *testing.T, mClock *quartz.Mock, tags []string) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) - mClock.TickerFunc(ctx, 30*time.Second, func() error { return nil }, "a") + mClock.TickerFunc(ctx, 30*time.Second, func() error { return nil }, tags...) }, "ticker from TickerFunc(..., [a])"}, } { t.Run(tc.name, func(t *testing.T) { t.Parallel() tb := &captureFailTB{TB: t} mClock := quartz.NewMock(tb) - tc.schedule(t, mClock) + tags := []string{"a"} + tc.schedule(t, mClock, tags) + // The caller may reuse its tags slice once the call returns. The failure still + // reports the tags the event was created with. + tags[0] = "reused" mClock.Advance(time.Hour) want := []string{ From 250e6842a4358640ae2be66f2cc6277fd407840d Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 12:36:53 +0000 Subject: [PATCH 04/16] docs(mock.go): say AdvanceWaiter waits for mock contexts The AdvanceWaiter, Wait, MustWait, and Done godoc said they wait for ticks and timers. They also wait for an expiring mock context: fire cancels it and runs the functions registered through its AfterFunc method before returning, so the context and the contexts derived from it directly are done when the waiter completes. --- mock.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/mock.go b/mock.go index 0081ade..d9be401 100644 --- a/mock.go +++ b/mock.go @@ -379,19 +379,19 @@ func (m *Mock) matchCallLocked(c *apiCall) { m.mu.Lock() } -// AdvanceWaiter is returned from Advance and Set calls and allows you to wait for ticks and timers -// to complete. In the case of functions passed to AfterFunc or TickerFunc, it waits for the +// AdvanceWaiter is returned from Advance and Set calls and allows you to wait for the triggered +// events to complete. In the case of functions passed to AfterFunc or TickerFunc, it waits for the // functions to return. For other ticks & timers, it just waits for the tick to be delivered to -// the channel. +// the channel. For a mock context, it waits until the context is done, along with the contexts +// derived from it directly, such as with context.WithCancel or this Mock's WithTimeout. // -// If multiple timers or tickers trigger simultaneously, they are all run on separate -// go routines. +// If multiple events trigger simultaneously, they are all run on separate go routines. type AdvanceWaiter struct { tb TestingT ch chan struct{} } -// Wait for all timers and ticks to complete, or until context expires. +// Wait for all triggered events to complete, or until ctx expires. func (w AdvanceWaiter) Wait(ctx context.Context) error { select { case <-w.ch: @@ -401,7 +401,7 @@ func (w AdvanceWaiter) Wait(ctx context.Context) error { } } -// MustWait waits for all timers and ticks to complete, and fails the test immediately if the +// MustWait waits for all triggered events to complete, and fails the test immediately if the // context completes first. MustWait must be called from the goroutine running the test or // benchmark, similar to `t.FailNow()`. func (w AdvanceWaiter) MustWait(ctx context.Context) { @@ -414,7 +414,7 @@ func (w AdvanceWaiter) MustWait(ctx context.Context) { } } -// Done returns a channel that is closed when all timers and ticks complete. +// Done returns a channel that is closed when all triggered events complete. func (w AdvanceWaiter) Done() <-chan struct{} { return w.ch } From 50641d706bf62fbf5edf2346c085520e744368c1 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 15:08:54 +0000 Subject: [PATCH 05/16] docs(mock.go): name the contexts AdvanceWaiter waits for "Derived from it directly" did not say which contexts count, and it undersold the guarantee: stdlib contexts derived through further WithCancel, WithTimeout, or WithDeadline layers are done too. It also hid the case that is not covered. A stdlib context derived through context.WithValue is canceled by the context package on its own goroutine, so it may still be live when Wait returns; in 200 runs it was not done right after MustWait in 178. --- mock.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mock.go b/mock.go index d9be401..9fb1340 100644 --- a/mock.go +++ b/mock.go @@ -382,8 +382,10 @@ func (m *Mock) matchCallLocked(c *apiCall) { // AdvanceWaiter is returned from Advance and Set calls and allows you to wait for the triggered // events to complete. In the case of functions passed to AfterFunc or TickerFunc, it waits for the // functions to return. For other ticks & timers, it just waits for the tick to be delivered to -// the channel. For a mock context, it waits until the context is done, along with the contexts -// derived from it directly, such as with context.WithCancel or this Mock's WithTimeout. +// the channel. For a mock context, it waits until the context is done, along with contexts derived +// from it through context.WithCancel, WithTimeout, WithDeadline, or this Mock's With methods. A +// context derived through context.WithValue is canceled on another goroutine, so it may not be done +// yet when Wait returns. // // If multiple events trigger simultaneously, they are all run on separate go routines. type AdvanceWaiter struct { From d3cefcc370b266e1c4666a7468c3a231635b8f80 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 15:09:04 +0000 Subject: [PATCH 06/16] docs(README.md): say "create" for timers, tickers, and mock contexts Nothing in the API "sets" a mock context, so "begin setting" did not describe what the reader does. --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2744627..051df39 100644 --- a/README.md +++ b/README.md @@ -65,9 +65,9 @@ mClock.Set(time.Date(2021, 6, 18, 12, 0, 0, 0, time.UTC)) // June 18, 2021 @ 12p #### Advancing the clock -Once you begin setting timers, tickers, or [mock contexts](#contexts), you cannot change the time -backward, only advance it forward. You may continue to use `Set()`, but it is often easier and -clearer to use `Advance()`. +Once you create timers, tickers, or [mock contexts](#contexts), you cannot change the time backward, +only advance it forward. You may continue to use `Set()`, but it is often easier and clearer to use +`Advance()`. For example, with a timer: From fb7c6dcdbb1d6bbfaca10b3a0168481f2b289096 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 15:09:13 +0000 Subject: [PATCH 07/16] docs(mock.go): comment the mockTickerFunc src field --- mock.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mock.go b/mock.go index 9fb1340..7ea98fa 100644 --- a/mock.go +++ b/mock.go @@ -697,7 +697,7 @@ type mockTickerFunc struct { done bool // err holds the error when the ticker exits err error - + // src is the mock call that created the ticker src eventSource } From c86f18adedbbe29badcc66fbf4f1b9ee5bb5c52a Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Tue, 3 Nov 2026 15:12:04 +0000 Subject: [PATCH 08/16] test(mock_test.go): test AdvanceNext firing contexts with Advance The AdvanceNext subtest of TestMock_FailureNamesBlockingEvents mostly checked that AdvanceNext fires a pending mock context, which is what TestWithTimeout_FiresOnAdvance checks for Advance. That test now runs once per way of moving the clock, so AdvanceNext also gets the check that a derived context is done after MustWait, and its row checks the duration AdvanceNext reports. The failure-message test is now one table of how the clock is moved and what blocks it. Set, setting the clock backward, and AdvanceNext with nothing scheduled are rows next to the Advance rows, so they share the setup and the check that reused tags do not change the message. --- mock_test.go | 225 +++++++++++++++++++++++++-------------------------- 1 file changed, 112 insertions(+), 113 deletions(-) diff --git a/mock_test.go b/mock_test.go index f539bbd..260a168 100644 --- a/mock_test.go +++ b/mock_test.go @@ -341,28 +341,46 @@ func TestTickerFunc_MockContextCanceled(t *testing.T) { } } -// A mock context expires when Advance reaches its deadline. By the time MustWait returns, it and -// the contexts derived from it report DeadlineExceeded and the deadline's cause. +// A mock context expires when Advance or AdvanceNext reaches its deadline. By the time MustWait +// returns, it and the contexts derived from it report DeadlineExceeded and the deadline's cause. func TestWithTimeout_FiresOnAdvance(t *testing.T) { t.Parallel() deadlineCause := errors.New("deadline cause") + withTimeout := func(mClock *quartz.Mock) (context.Context, context.CancelFunc) { + return mClock.WithTimeout(context.Background(), time.Second) + } + advance := func(_ *testing.T, mClock *quartz.Mock) quartz.AdvanceWaiter { + return mClock.Advance(time.Second) + } tests := map[string]struct { newCtx func(mClock *quartz.Mock) (context.Context, context.CancelFunc) + move func(t *testing.T, mClock *quartz.Mock) quartz.AdvanceWaiter wantCause error }{ "WithTimeout": { - newCtx: func(mClock *quartz.Mock) (context.Context, context.CancelFunc) { - return mClock.WithTimeout(context.Background(), time.Second) - }, + newCtx: withTimeout, + move: advance, wantCause: context.DeadlineExceeded, }, "WithTimeoutCause": { newCtx: func(mClock *quartz.Mock) (context.Context, context.CancelFunc) { return mClock.WithTimeoutCause(context.Background(), time.Second, deadlineCause) }, + move: advance, wantCause: deadlineCause, }, + "AdvanceNext": { + newCtx: withTimeout, + move: func(t *testing.T, mClock *quartz.Mock) quartz.AdvanceWaiter { + d, w := mClock.AdvanceNext() + if d != time.Second { + t.Errorf("expected AdvanceNext to advance 1s, got %s", d) + } + return w + }, + wantCause: context.DeadlineExceeded, + }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { @@ -376,7 +394,7 @@ func TestWithTimeout_FiresOnAdvance(t *testing.T) { ctxs := descendants(t, mClock, ctx) ctxs["Self"] = ctx - mClock.Advance(time.Second).MustWait(testCtx) + tt.move(t, mClock).MustWait(testCtx) for name, c := range ctxs { if !errors.Is(c.Err(), context.DeadlineExceeded) { @@ -1209,124 +1227,105 @@ func TestWithTimeout_Trap(t *testing.T) { } } -// The failures name the events that stop the clock, so that a pending mock context is not mistaken -// for a timer or ticker. +// Failures to move the clock name the events that block it, and the AdvanceNext failure lists the +// kinds of event it looks for, so a pending mock context is not mistaken for a timer or ticker. func TestMock_FailureNamesBlockingEvents(t *testing.T) { t.Parallel() - t.Run("Advance", func(t *testing.T) { - t.Parallel() - for _, tc := range []struct { - name string - schedule func(t *testing.T, mClock *quartz.Mock, tags []string) - want string - }{ - {"WithTimeout", func(t *testing.T, mClock *quartz.Mock, tags []string) { + advanceHour := func(mClock *quartz.Mock) { mClock.Advance(time.Hour) } + tests := map[string]struct { + schedule func(t *testing.T, mClock *quartz.Mock, tags []string) + move func(mClock *quartz.Mock) + want string + }{ + "AdvancePastWithTimeout": { + schedule: func(t *testing.T, mClock *quartz.Mock, tags []string) { _, cancel := mClock.WithTimeout(context.Background(), 30*time.Second, tags...) t.Cleanup(cancel) - }, "mock context from WithTimeout(..., [a])"}, - {"NewTimer", func(_ *testing.T, mClock *quartz.Mock, tags []string) { + }, + move: advanceHour, + want: "cannot advance 1h0m0s which is beyond next event in 30s: " + + "mock context from WithTimeout(..., [a])", + }, + "AdvancePastNewTimer": { + schedule: func(_ *testing.T, mClock *quartz.Mock, tags []string) { mClock.NewTimer(30*time.Second, tags...) - }, "timer from NewTimer(..., [a])"}, - {"AfterFunc", func(_ *testing.T, mClock *quartz.Mock, tags []string) { + }, + move: advanceHour, + want: "cannot advance 1h0m0s which is beyond next event in 30s: " + + "timer from NewTimer(..., [a])", + }, + "AdvancePastAfterFunc": { + schedule: func(_ *testing.T, mClock *quartz.Mock, tags []string) { mClock.AfterFunc(30*time.Second, func() {}, tags...) - }, "timer from AfterFunc(..., [a])"}, - {"NewTicker", func(_ *testing.T, mClock *quartz.Mock, tags []string) { + }, + move: advanceHour, + want: "cannot advance 1h0m0s which is beyond next event in 30s: " + + "timer from AfterFunc(..., [a])", + }, + "AdvancePastNewTicker": { + schedule: func(_ *testing.T, mClock *quartz.Mock, tags []string) { mClock.NewTicker(30*time.Second, tags...) - }, "ticker from NewTicker(..., [a])"}, - {"TickerFunc", func(t *testing.T, mClock *quartz.Mock, tags []string) { + }, + move: advanceHour, + want: "cannot advance 1h0m0s which is beyond next event in 30s: " + + "ticker from NewTicker(..., [a])", + }, + "AdvancePastTickerFunc": { + schedule: func(t *testing.T, mClock *quartz.Mock, tags []string) { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) mClock.TickerFunc(ctx, 30*time.Second, func() error { return nil }, tags...) - }, "ticker from TickerFunc(..., [a])"}, - } { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - tb := &captureFailTB{TB: t} - mClock := quartz.NewMock(tb) - tags := []string{"a"} - tc.schedule(t, mClock, tags) - // The caller may reuse its tags slice once the call returns. The failure still - // reports the tags the event was created with. - tags[0] = "reused" - - mClock.Advance(time.Hour) - want := []string{ - "cannot advance 1h0m0s which is beyond next event in 30s: " + tc.want, - } - if !slices.Equal(tb.errs, want) { - t.Fatalf("expected errors %q, got %q", want, tb.errs) - } - }) - } - }) - - t.Run("Set", func(t *testing.T) { - t.Parallel() - tb := &captureFailTB{TB: t} - mClock := quartz.NewMock(tb) - start := mClock.Now() - mClock.NewTimer(30 * time.Second) - _, cancel := mClock.WithDeadline(context.Background(), start.Add(30*time.Second), "a") - defer cancel() - - mClock.Set(start.Add(time.Hour)) - want := []string{"cannot Set time to 2024-01-01 01:00:00 +0000 UTC which is beyond next " + - "event at 2024-01-01 00:00:30 +0000 UTC: timer from NewTimer(..., []), " + - "mock context from WithDeadline(..., [a])"} - if !slices.Equal(tb.errs, want) { - t.Fatalf("expected errors %q, got %q", want, tb.errs) - } - }) - - t.Run("SetPast", func(t *testing.T) { - t.Parallel() - tb := &captureFailTB{TB: t} - mClock := quartz.NewMock(tb) - start := mClock.Now() - _, cancel := mClock.WithTimeout(context.Background(), 30*time.Second, "a") - defer cancel() - - mClock.Set(start.Add(-time.Hour)) - want := []string{"Set mock clock to the past while events are scheduled, next at " + - "2024-01-01 00:00:30 +0000 UTC: mock context from WithTimeout(..., [a])"} - if !slices.Equal(tb.errs, want) { - t.Fatalf("expected errors %q, got %q", want, tb.errs) - } - }) - - t.Run("AdvanceNext", func(t *testing.T) { - t.Parallel() - testCtx, testCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer testCancel() - tb := &captureFailTB{TB: t} - mClock := quartz.NewMock(tb) - ctx, cancel := mClock.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // A pending mock context is the next event. - d, w := mClock.AdvanceNext() - if err := w.Wait(testCtx); err != nil { - t.Fatalf("waiting for AdvanceNext: %v", err) - } - if d != 30*time.Second { - t.Fatalf("expected AdvanceNext to advance 30s, got %s", d) - } - if !errors.Is(ctx.Err(), context.DeadlineExceeded) { - t.Fatalf("expected DeadlineExceeded, got %v", ctx.Err()) - } - if len(tb.errs) != 0 { - t.Fatalf("expected no errors, got %q", tb.errs) - } - - mClock.AdvanceNext() - want := []string{ - "cannot AdvanceNext because no timers, tickers, or mock contexts are scheduled", - } - if !slices.Equal(tb.errs, want) { - t.Fatalf("expected errors %q, got %q", want, tb.errs) - } - }) + }, + move: advanceHour, + want: "cannot advance 1h0m0s which is beyond next event in 30s: " + + "ticker from TickerFunc(..., [a])", + }, + "SetPastTwoEvents": { + schedule: func(t *testing.T, mClock *quartz.Mock, tags []string) { + mClock.NewTimer(30 * time.Second) + deadline := mClock.Now().Add(30 * time.Second) + _, cancel := mClock.WithDeadline(context.Background(), deadline, tags...) + t.Cleanup(cancel) + }, + move: func(mClock *quartz.Mock) { mClock.Set(mClock.Now().Add(time.Hour)) }, + want: "cannot Set time to 2024-01-01 01:00:00 +0000 UTC which is beyond next event at " + + "2024-01-01 00:00:30 +0000 UTC: timer from NewTimer(..., []), " + + "mock context from WithDeadline(..., [a])", + }, + "SetBackward": { + schedule: func(t *testing.T, mClock *quartz.Mock, tags []string) { + _, cancel := mClock.WithTimeout(context.Background(), 30*time.Second, tags...) + t.Cleanup(cancel) + }, + move: func(mClock *quartz.Mock) { mClock.Set(mClock.Now().Add(-time.Hour)) }, + want: "Set mock clock to the past while events are scheduled, next at " + + "2024-01-01 00:00:30 +0000 UTC: mock context from WithTimeout(..., [a])", + }, + "AdvanceNextWithNothingScheduled": { + schedule: func(*testing.T, *quartz.Mock, []string) {}, + move: func(mClock *quartz.Mock) { mClock.AdvanceNext() }, + want: "cannot AdvanceNext because no timers, tickers, or mock contexts are " + + "scheduled", + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + tb := &captureFailTB{TB: t} + mClock := quartz.NewMock(tb) + tags := []string{"a"} + tt.schedule(t, mClock, tags) + // The caller may reuse its tags slice once the call returns. The failure still reports + // the tags the event was created with. + tags[0] = "reused" + + tt.move(mClock) + if want := []string{tt.want}; !slices.Equal(tb.errs, want) { + t.Fatalf("expected errors %q, got %q", want, tb.errs) + } + }) + } } func Test_MultipleTraps(t *testing.T) { From 2bdbd52abe0ccdfaa54b31ba2f94f87528f120e6 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 18:23:43 +0000 Subject: [PATCH 09/16] docs(mock.go): drop the WithValue exception from AdvanceWaiter Since #30, contexts derived through context.WithValue are canceled on the canceling goroutine, so they are done when Wait returns like every other derived context. In 500 runs under -race, none of the derived contexts tried (WithValue, WithCancel or WithTimeout over WithValue, a struct embedding the context) was live after MustWait; before #30's changes, the WithValue ones were live in about a quarter of runs. --- mock.go | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/mock.go b/mock.go index 7ea98fa..c2d1859 100644 --- a/mock.go +++ b/mock.go @@ -381,11 +381,9 @@ func (m *Mock) matchCallLocked(c *apiCall) { // AdvanceWaiter is returned from Advance and Set calls and allows you to wait for the triggered // events to complete. In the case of functions passed to AfterFunc or TickerFunc, it waits for the -// functions to return. For other ticks & timers, it just waits for the tick to be delivered to -// the channel. For a mock context, it waits until the context is done, along with contexts derived -// from it through context.WithCancel, WithTimeout, WithDeadline, or this Mock's With methods. A -// context derived through context.WithValue is canceled on another goroutine, so it may not be done -// yet when Wait returns. +// functions to return. For other ticks & timers, it just waits for the tick to be delivered to the +// channel. For a mock context, it waits until the context and the contexts derived from it are +// done. // // If multiple events trigger simultaneously, they are all run on separate go routines. type AdvanceWaiter struct { From 2f0bf67af2a68e44d0536886eaed5854d2c5f551 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 18:23:43 +0000 Subject: [PATCH 10/16] style: fill this branch's comments and README lines to 100 columns --- README.md | 4 ++-- mock.go | 13 ++++++------- mock_test.go | 5 ++--- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 051df39..8fc2286 100644 --- a/README.md +++ b/README.md @@ -369,8 +369,8 @@ contexts created after the test ended are returned already canceled with that ca Call the returned cancel function as you would for `context.WithTimeout`. It is idempotent and removes the pending mock event. Cause variants preserve the cause for `context.Cause`. -A pending mock context also limits how far `Advance()` can move the clock; see -[Advance only to the next event](#advance-only-to-the-next-event). +A pending mock context also limits how far `Advance()` can move the clock; see [Advance only to the +next event](#advance-only-to-the-next-event). #### `Deadline()` does not report mock time diff --git a/mock.go b/mock.go index c2d1859..95ee696 100644 --- a/mock.go +++ b/mock.go @@ -475,8 +475,8 @@ func (m *Mock) advanceLocked(w AdvanceWaiter) { } // Set the time to t. If the time is after the current mocked time, then this is equivalent to -// Advance() with the difference. You may only Set the time earlier than the current time while -// no events are scheduled (e.g. at the start of your test case). +// Advance() with the difference. You may only Set the time earlier than the current time while no +// events are scheduled (e.g. at the start of your test case). func (m *Mock) Set(t time.Time) AdvanceWaiter { m.tb.Helper() w := AdvanceWaiter{tb: m.tb, ch: make(chan struct{})} @@ -540,8 +540,8 @@ func (m *Mock) AdvanceNext() (time.Duration, AdvanceWaiter) { return d, w } -// Peek returns the duration until the next scheduled event and the value true, or, if there are -// no scheduled events, it returns zero and false. +// Peek returns the duration until the next scheduled event and the value true, or, if there are no +// scheduled events, it returns zero and false. func (m *Mock) Peek() (d time.Duration, ok bool) { m.checkDoneContexts() m.mu.Lock() @@ -656,9 +656,8 @@ func (m *Mock) WithLogger(l Logger) *Mock { return m } -// 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 any events are -// scheduled. +// 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 any events are scheduled. func NewMock(tb TestingT) *Mock { cur, err := time.Parse(time.RFC3339, "2024-01-01T00:00:00Z") if err != nil { diff --git a/mock_test.go b/mock_test.go index 260a168..568761f 100644 --- a/mock_test.go +++ b/mock_test.go @@ -1582,9 +1582,8 @@ func TestTimerReset_NegativeDuration(t *testing.T) { t.Fatal("Reset should report the timer was active") } - // The timer's old 1h schedule must not remain in the mock's event list. - // If it does, Advance rejects with "cannot advance 2h0m0s which is beyond - // next event". + // The timer's old 1h schedule must not remain in the mock's event list. If it does, Advance + // rejects with "cannot advance 2h0m0s which is beyond next event". if d, ok := mClock.Peek(); ok { t.Fatalf("expected no pending events, got Peek()=%s", d) } From aef960fb47a4e57dc18c3b018e2456f89cbd10ba Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 18:49:25 +0000 Subject: [PATCH 11/16] docs(mock.go): name the derived contexts AdvanceWaiter waits for "The contexts derived from it" claimed too much. In 300 runs under -race, a context from context.WithoutCancel was live after MustWait in all of them, as designed, and context.WithCancel over a Context type with its own Done channel was live in 88, because the context package cancels it from a goroutine. Contexts from the other context.With functions, also over WithValue, and from the Mock's With methods were done in every run. --- mock.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/mock.go b/mock.go index 95ee696..e12c7b9 100644 --- a/mock.go +++ b/mock.go @@ -382,8 +382,10 @@ func (m *Mock) matchCallLocked(c *apiCall) { // AdvanceWaiter is returned from Advance and Set calls and allows you to wait for the triggered // events to complete. In the case of functions passed to AfterFunc or TickerFunc, it waits for the // functions to return. For other ticks & timers, it just waits for the tick to be delivered to the -// channel. For a mock context, it waits until the context and the contexts derived from it are -// done. +// channel. For a mock context, it waits until the context is done, along with the contexts derived +// from it with the context package's With functions, other than WithoutCancel, or with this Mock's +// With methods. A context derived through a Context implementation with its own Done channel may +// still be live, because the context package cancels it on another goroutine. // // If multiple events trigger simultaneously, they are all run on separate go routines. type AdvanceWaiter struct { From 23973578465726a95caefe6da0132d45eec2ef0e Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 18:49:58 +0000 Subject: [PATCH 12/16] docs(README.md): limit the no-going-back rule to scheduled events Set moves the clock backward once nothing is scheduled again, as its godoc says. A probe set it back an hour without an error after stopping a timer and a ticker, canceling one mock context, letting another expire, and canceling the stdlib parent of a third. --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8fc2286..9b0593b 100644 --- a/README.md +++ b/README.md @@ -65,9 +65,10 @@ mClock.Set(time.Date(2021, 6, 18, 12, 0, 0, 0, time.UTC)) // June 18, 2021 @ 12p #### Advancing the clock -Once you create timers, tickers, or [mock contexts](#contexts), you cannot change the time backward, -only advance it forward. You may continue to use `Set()`, but it is often easier and clearer to use -`Advance()`. +While a timer, ticker, or [mock context](#contexts) is scheduled, you cannot change the time +backward, only advance it forward. A timer is scheduled until it fires or is stopped, a ticker until +it is stopped, and a mock context until it expires or is canceled. You may continue to use `Set()`, +but it is often easier and clearer to use `Advance()`. For example, with a timer: From f6bb53bf22171e042900a7711f8bd08da764798b Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 18:50:13 +0000 Subject: [PATCH 13/16] docs(mock.go): match the NewMock godoc to Set's backward rule --- mock.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mock.go b/mock.go index e12c7b9..09ee62a 100644 --- a/mock.go +++ b/mock.go @@ -659,7 +659,7 @@ func (m *Mock) WithLogger(l Logger) *Mock { } // 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 any events are scheduled. +// time earlier than this, but only while no events are scheduled. func NewMock(tb TestingT) *Mock { cur, err := time.Parse(time.RFC3339, "2024-01-01T00:00:00Z") if err != nil { From 6094e012bc3579cfe5566fc2cafdddc8f67225a5 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 19:10:59 +0000 Subject: [PATCH 14/16] docs(mock.go): refer AdvanceWaiter to the WithDeadline context list Clock.WithDeadline on #30 now lists which derived contexts are done when MustWait returns, so AdvanceWaiter refers to that list instead of keeping its own copy, which had also said "this Mock's" where contexts from another Mock's With methods are done too. --- mock.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/mock.go b/mock.go index 09ee62a..a40f842 100644 --- a/mock.go +++ b/mock.go @@ -382,10 +382,8 @@ func (m *Mock) matchCallLocked(c *apiCall) { // AdvanceWaiter is returned from Advance and Set calls and allows you to wait for the triggered // events to complete. In the case of functions passed to AfterFunc or TickerFunc, it waits for the // functions to return. For other ticks & timers, it just waits for the tick to be delivered to the -// channel. For a mock context, it waits until the context is done, along with the contexts derived -// from it with the context package's With functions, other than WithoutCancel, or with this Mock's -// With methods. A context derived through a Context implementation with its own Done channel may -// still be live, because the context package cancels it on another goroutine. +// channel. For a mock context, it waits until the context is done, along with the derived contexts +// that Clock.WithDeadline lists. // // If multiple events trigger simultaneously, they are all run on separate go routines. type AdvanceWaiter struct { From d5517fc2c8c13b3a95c40b631a1342c9e9b2101f Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 19:11:05 +0000 Subject: [PATCH 15/16] docs(README.md): show the Advance failure that names the next event The message was produced by running the TestAdvanceTooFar example, with its context.WithTimeout call given a parent so it compiles. --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 9b0593b..5d3d44b 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,12 @@ func TestAdvanceTooFar(t *testing.T) { } ``` +The failure names the events that stop the clock and the calls that created them: + +``` +cannot advance 2s which is beyond next event in 1s: timer from AfterFunc(..., []) +``` + This is a deliberate design decision to allow `Advance()` to immediately and synchronously move the clock forward (even without calling `Wait()` on returned waiter). This helps meet Quartz's design goals of writing deterministic and easy to understand unit tests. It also allows the clock to be From 1ce3be260acc6670c86899088b56cd5b6f629193 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Wed, 4 Nov 2026 19:11:19 +0000 Subject: [PATCH 16/16] style(mock_test.go): split the SetPastTwoEvents message at 100 columns --- mock_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mock_test.go b/mock_test.go index 568761f..780dbef 100644 --- a/mock_test.go +++ b/mock_test.go @@ -1289,9 +1289,9 @@ func TestMock_FailureNamesBlockingEvents(t *testing.T) { t.Cleanup(cancel) }, move: func(mClock *quartz.Mock) { mClock.Set(mClock.Now().Add(time.Hour)) }, - want: "cannot Set time to 2024-01-01 01:00:00 +0000 UTC which is beyond next event at " + - "2024-01-01 00:00:30 +0000 UTC: timer from NewTimer(..., []), " + - "mock context from WithDeadline(..., [a])", + want: "cannot Set time to 2024-01-01 01:00:00 +0000 UTC " + + "which is beyond next event at 2024-01-01 00:00:30 +0000 UTC: " + + "timer from NewTimer(..., []), mock context from WithDeadline(..., [a])", }, "SetBackward": { schedule: func(t *testing.T, mClock *quartz.Mock, tags []string) {