Repository navigation
Conversation
This was referenced Nov 3, 2026
mafredri
force-pushed
the
mafredri/context-synctest-docs
branch
from
November 3, 2026 15:16
291169d to
ab3838c
Compare
Since Go 1.25, synctest gives tests a fake clock without injecting one, so users need to know when a *Mock is still the right tool and what happens when they combine the two. Each statement in the section was checked on Go 1.25.8, 1.26.5, and 1.27.3: NewReal() follows bubble time, a Mock keeps its own time, a Mock created with the outer t crashes the test binary at cleanup, and a real PostgreSQL query keeps bubble time from advancing.
The list of leftovers that crash with the outer t was incomplete. A fired NewTimer registers a cleanup that receives on a bubble channel even after its channel was read or the timer was stopped, and a mock context's cleanup calls parent.Err(), which receives on the parent's Done channel when that parent came from the bubble, such as t.Context(), even after cancel. Describing the mechanism covers these without a list that has to track mock.go and timer.go.
The section only said a stuck test times out without waiting in wall time. A test timeout created in the bubble also expires on bubble time that passes while nothing is stuck, so a 10 second timeout fails a test that sleeps for an hour or whose code under test waits on an hour-long real timer.
The intro named httptest.NewTestServer without saying what it changes, and the network I/O bullet sent every networked test outside the bubble. A request to a NewTestServer from its Client() advances bubble time while in flight, while the same request to a loopback httptest.NewServer keeps bubble time stopped, so on Go 1.27 HTTP tests need neither a real network nor a *Mock.
The bullet counted any sync.WaitGroup wait as durable. Wait only lets bubble time advance when Add was called in the bubble: with Add called outside, or with a package-level var WaitGroup, bubble time stayed at zero until a goroutine outside the bubble called Done. The full list now sits in the intro, and the bullet refers to it.
The quoted failure was not the only outcome. After time.Sleep(time.Hour) the 10 second timeout is always expired, but what MustWait then does depends on the pending mock event: with AfterFunc it usually fails with the quoted message and sometimes passes, because the AdvanceWaiter can already be done when MustWait selects; with NewTimer it usually also aborts the binary with a synctest deadlock panic. A MustWait that has to wait always fails, so the text now says that.
The bullet did not say where to create the server. A NewTestServer created outside the bubble crashed the test binary on Go 1.27.3 with "select on synctest channel from outside bubble" whether Client() was called outside or inside the bubble: during the request in the first case, and when the outer test's cleanup closed the server in the second.
The section listed synctest facts and failure cases, so a reader who wanted to know whether to use synctest, a Mock, or both had to work the answer out. It now starts with that choice, shows the NewReal() option, points to Mocking for the other, and keeps only the two mistakes users can still make: creating the Mock with the outer t and sizing test timeouts that run on bubble time. The synctest docs already define durable blocking, so the section links there instead of restating it.
Only bubble time that passes while a timeout is in use counts against it: a 10 second timeout created after time.Sleep(time.Hour) still let MustWait return. The advice also left out why a long timeout is safe in a bubble: a stuck trap.MustWait with a 1 hour timeout failed after about 120 microseconds of wall time.
"Fits in a bubble" left the reader to work out what fits. Real I/O is what keeps a test out of a bubble in the next bullet, so the first bullet now names its absence and the two read as a pair.
"On Go 1.27" read as the toolchain version. With the go1.27.3 toolchain, a module declaring go 1.24, 1.25, or 1.26 fails to build with "httptest.NewTestServer requires go1.27 or later (module is go1.26)", and the same test passes once go.mod says go 1.27.
"Which runs on bubble time there" did not say what the user does. The bullet now says to run the test in synctest.Test and keep the clock the code already uses, and why that removes the need for a *Mock.
The bullet said bubble time stops during I/O but not why that matters. Inside a bubble, a 1 second timeout did not stop a 2 second request to a real HTTP server, and a 10 millisecond timeout did not stop a 1 second PostgreSQL query; both returned normally with 0s of bubble time passed.
"Bubble time does not move it" left open what does. In a bubble, an hour of time.Sleep left the mock at its start time, and Advance(), AdvanceNext(), and Set() each moved the mock without moving bubble time.
Without the comment, synctest.Wait reads as optional. context.WithTimeout cancels from a time.AfterFunc callback that runs in its own goroutine, so after time.Sleep returns, ctx may not be done yet: without Wait the example failed in roughly half of 2000 runs on Go 1.25.8, 1.26.5, and 1.27.3, and with it every run passed.
"Including time.Sleep calls" did not show the failure the reader should expect. A 10 second context created in a bubble reported DeadlineExceeded after a one-minute time.Sleep on Go 1.25.8, 1.26.5, and 1.27.3.
"A stuck test reaches it immediately" is false when the stuck goroutine waits on a mutex, which synctest does not count as durably blocked: a trap.MustWait with a 1 hour timeout hung until go test -timeout on Go 1.25.8, 1.26.5, and 1.27.3. With every goroutine durably blocked, the same wait failed after about 120 microseconds of wall time.
The audit fixes added about 50 words. This shortens the wording around them without dropping any of the added facts.
Probes on Go 1.26.5: with an httptest.NewServer open in the bubble, time.Sleep, a context timeout, and synctest.Wait each hung until go test's timeout; so did time.Sleep with a goroutine reading an idle TCP connection, and synctest.Test itself when an HTTP client's idle keep-alive connection was left open. A server created and closed with no wait in between, and a connection nobody reads, did not hang.
In a bubble, a *Mock's traps that only wait for the code to reach a clock call can be replaced by synctest.Wait. In 2,000 runs each, Advance missed a timer created by another goroutine in 1,999 runs with neither, and in none with synctest.Wait or a trap. The traps bullet no longer says "in a bubble or not": the choice it describes does not depend on the bubble.
The fatal error names the channel operation that crossed the bubble, for example "receive on synctest channel from outside bubble" in a probe that created a context and a ticker in the bubble, so the README quotes only the part every variant shares. The intro paragraph is refilled to 100 columns.
synctest.Wait returns at the first point where every goroutine is durably blocked. A worker that sleeps or waits on a channel before its clock call is blocked there, so the timer does not exist yet and a following Advance misses it: on Go 1.25.8, 1.26.5, and 1.27.3, Peek after Wait found no timer in both cases, and found it when the worker made the call first. A trap waits for the call itself.
The real-I/O bullet and the network bullet both explained that bubble time stops while a goroutine waits on I/O. The real-I/O bullet now states it, including that synctest.Wait does not return, and the network bullet refers to it.
"Jumps straight to it" read as if bubble time skipped to the long timeout itself. It moves to the earliest pending timer, so a long timeout fires without wall time only once no earlier timer is pending; in a probe, a 24-hour timeout with nothing else pending expired after about 60 microseconds of wall time.
synctest.Test needs go 1.25 in go.mod, not only a Go 1.25 toolchain: with go 1.24 in go.mod, go vet on Go 1.26.5 reports "synctest.Test requires go1.25 or later". The Mock bullet now says it applies inside or outside a bubble.
mafredri
force-pushed
the
mafredri/context-synctest-docs
branch
from
November 4, 2026 19:17
ab3838c to
23344f0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Go 1.25 made
testing/syncteststable. Users who find it will ask whether they still need a*Mock. This adds a short README section that answers that:synctestwithquartz.NewReal()when the test does no real I/O.*Mockwhen it does, or when the test needs traps or control over when time moves.It also covers the mistakes that remain when combining them: an
httptest.NewServeror an idle network connection in the bubble, which stops bubble time and hangs the test; creating the*Mockwith the outer test'st; and test timeouts inside a bubble running on bubble time. With a*Mockin a bubble,synctest.Wait()can replace a trap that only waits for the code to reach a clock call, but only when the code makes that call before it first blocks; otherwiseWaitreturns early and the followingAdvancemisses the timer.Every claim was checked by experiment on Go 1.25.8, 1.26.5, and 1.27.3, except the
httptest.NewTestServerclaim, which needsgo 1.27ingo.modand was checked on 1.27.3. The example compiles and passes on all three. Whether quartz should offer anything synctest-specific is open in #35.Stacked on #30 and depends on it: the example calls
Clock.WithTimeout, which #30 adds.When squash merging, please replace the commit body with this description; several commit messages describe wording that later commits changed.