Skip to content

docs(README.md): add testing/synctest guidance - #34

Draft
mafredri wants to merge 26 commits into
mafredri/contextfrom
mafredri/context-synctest-docs
Draft

mafredri wants to merge 26 commits into
mafredri/contextfrom
mafredri/context-synctest-docs

Conversation

@mafredri

@mafredri mafredri commented Nov 3, 2026 •

Copy link
Copy Markdown
Member

Go 1.25 made testing/synctest stable. Users who find it will ask whether they still need a *Mock. This adds a short README section that answers that:

  • Use synctest with quartz.NewReal() when the test does no real I/O.
  • Use a *Mock when 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.NewServer or an idle network connection in the bubble, which stops bubble time and hangs the test; creating the *Mock with the outer test's t; and test timeouts inside a bubble running on bubble time. With a *Mock in 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; otherwise Wait returns early and the following Advance misses the timer.

Every claim was checked by experiment on Go 1.25.8, 1.26.5, and 1.27.3, except the httptest.NewTestServer claim, which needs go 1.27 in go.mod and 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.

🤖 This PR was created with the help of Coder Agents.

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
mafredri force-pushed the mafredri/context-synctest-docs branch from ab3838c to 23344f0 Compare November 4, 2026 19:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant