Revisiting time, with testing/synctest
You can find all the code for this chapter here
In the chapter on time we gave our poker CLI the ability to schedule "blind is now going up" alerts, using time.AfterFunc. Testing this was tricky: time.AfterFunc runs its callback in its own goroutine after a real duration elapses, and you can't compare functions in Go, so we couldn't easily inspect what had been scheduled.
We solved that with a familiar tool: dependency injection. We defined a BlindAlerter interface, and in our tests we swapped the real implementation for a spy that just records what it was asked to schedule, something like this:
type BlindAlerter interface {
ScheduleAlertAt(duration time.Duration, amount int)
}
type SpyBlindAlerter struct {
Alerts []struct {
At time.Duration
Amount int
}
}
func (s *SpyBlindAlerter) ScheduleAlertAt(at time.Duration, amount int) {
s.Alerts = append(s.Alerts, struct {
At time.Duration
Amount int
}{at, amount})
}This is a good design, and the tests it enables are fast and reliable. But look closely at what they actually cover. They assert things like "ScheduleAlertAt was called with 10 * time.Minute and 200". They never let a real alerter run. The one piece of code that actually calls time.AfterFunc and prints something, the part with the real bug potential, never gets exercised by a test.
That's not an oversight, it's a trade-off. Testing a real time.AfterFunc-based alerter properly would mean either waiting real minutes for a test to finish, or shrinking the durations down to milliseconds and hoping the machine running the test isn't too busy to keep up. Neither is appealing. So historically, we just didn't.
As of Go 1.25, there's a third option: testing/synctest. It lets us run real, unmodified code that uses time.Sleep, time.AfterFunc, and friends, inside a test that has full control over time: no waiting, no flakiness, no shrinking durations and hoping for the best.
In this chapter we'll build that real alerter from scratch, and test it with synctest. But let's earn it, by first seeing exactly what it saves us from.
Just enough information on testing/synctest
testing/synctest runs a function inside an isolated bubble. Within that bubble:
The
timepackage uses a fake clock. It starts at midnight UTC on the 1st of January, 2000, and only moves forward.Fake time only advances when every goroutine in the bubble is durably blocked: blocked in a way that only another goroutine in the same bubble can unblock it.
time.Sleep, a blocking receive on a channel created inside the bubble,sync.Cond.Wait, andsync.WaitGroup.Waitall count. Locking async.Mutexdoes not, because mutexes are typically held only briefly.synctest.Test(t, f)runsfin a new bubble and doesn't return until every goroutine spawned inside it has exited. If the bubble ends up durably blocked with no way to make progress, it fails the test as a deadlock rather than hanging forever.synctest.Wait()blocks the calling goroutine until every other goroutine in the bubble is durably blocked, then returns. It's how you let background work settle before making an assertion.
That's enough to get started. We'll pick up a couple of sharper edges as we go.
Write the test first
Let's start with the obvious thing, before reaching for synctest at all: a BlindAlerter that, given a duration and an amount, waits for that duration and then writes a message somewhere, tested with real time.
We sleep a little longer than the scheduled alert (6 seconds, not 5) to give the goroutine time.AfterFunc spawns a moment to actually run before we check.
Try to run the test
We haven't written any production code yet, so this won't compile:
Write enough code to make it pass
Run it:
It passes. It also takes six real seconds, for one alert. Our actual game schedules eleven of them, some as far as an hour and forty minutes apart. Nobody is going to wait for that on every test run, so in practice this test would either get skipped, or written with unrealistically tiny durations that only vaguely resemble the real thing. This is the problem synctest exists to solve.
Introducing synctest
Let's wrap the same test in a bubble and see what happens if we're a bit too optimistic about what a "fake clock" does for us:
We dropped the sleep entirely: surely the fake clock handles that for us now? It doesn't:
A bubble's fake clock doesn't run itself forward on a timer of its own. It only advances when something in the bubble durably blocks, waiting for it to. We still have to say what we're waiting for; we just don't have to pay for it in real seconds any more. Let's put the sleep back:
Passes, and instantly: time.Sleep(6 * time.Second) inside a bubble costs nothing in real time. Looks done. Let's just double-check it holds up under -race, out of habit:
Ouch. Notice "Goroutine 11 (finished)": the write genuinely happened before our read, every single time we ran it. Functionally, the test can never actually fail on the assertion. But the race detector isn't asking "did this go wrong this time?"; it's asking "is there anything that guarantees it can't?" time.Sleep on our goroutine and time.AfterFunc's callback on its own goroutine are just two independently-scheduled goroutines. Nothing about "I slept for a while" promises another goroutine has finished touching shared memory, no matter how generous the sleep. Sleeping longer doesn't fix this, no matter how long; it just makes it fail less often outside of -race.
Worth sitting with for a second: this exact race was already lurking in our very first, plain real-time version too, with a real time.Sleep. We just never thought to check: who runs -race, repeatedly, on a test that already takes six real seconds to run?
Write the test first
What we need is a real synchronization point: something that doesn't just make the write probably happen first, but actually establishes it did. That's exactly what synctest.Wait() is for:
Refactor
There's nothing to change in the production code, but let's make sure this one actually holds up, repeatedly, under -race:
Clean, every time. Very nice.
Write the test first
There's another thing worth testing: that nothing has happened yet. With real time, that means guessing at a sleep that's long enough to be confident, but not so long the test drags. synctest doesn't need the guess:
Nothing else in the bubble is doing anything at the point we schedule the alert, so Wait() should return straight away: nothing to wait for yet, so out should still be empty. Run it:
Passes. Let's check -race, as we now know we should:
The race is at line 18: our very first check, the one we were confident about, because there was "nothing to wait for yet". Reliably, every run.
The issue is that Wait() alone has nothing bounding how far it's willing to let the fake clock run. There's no other goroutine in the bubble at that point, and no upcoming deadline for us to wait on. Only the alert's 5-second timer, sitting there in the runtime's timer heap, is left. So the runtime does the only useful thing it can: it fires the timer to make progress, spawning goroutine 11 to run our callback, while Wait() is still deciding whether to return. Goroutine 11 is genuinely running concurrently with our check, not before it. Sometimes that write finishes first; sometimes it doesn't. The bytes.Buffer we're both touching has no lock, so there's nothing making that safe either way.
We could fix this the way we'd fix any data race: wrap out in a sync.Mutex, or use sync/atomic's atomic.Pointer[T] for a lighter touch. But look at what StdOutAlerter is actually being asked to do: decide the message and perform the side effect of printing it. Nothing about scheduling a poker blind alert requires knowing about io.Writer; that's the caller's business. That's the shape of tension this book keeps coming back to: if your tests are causing you pain, listen to that signal and think about the design of your code. Let's have the alerter just produce the message when it's due, and let the caller decide what to do with it.
Refactor
Crossing a goroutine boundary is exactly what channels are for. As the Go proverb goes, don't communicate by sharing memory; share memory by communicating. Instead of writing into a shared out, our alerter can send the finished message down a channel:
BlindAlerter and BlindAlerterFunc stay exactly as they were. Only StdOutAlerter (which, tellingly, no longer had anything to do with stdout) is gone, replaced by NewAlerter, handing back both the alerter and a channel to receive from. Whatever wants these alerts printed (main, say) can range over that channel and print them; that's no longer this package's problem.
The test gets simpler too:
Notice what's gone: no synctest.Wait() anywhere, no custom type to guard a shared value, no time.Sleep at all. We don't need any of them any more.
The select with a default case, straight out of the chapter on select, never blocks: it either takes a ready case or falls through to default immediately. At this point virtual time is still sitting at zero, five whole (fake) seconds shy of the alert, so there's nothing to synchronize: of course nothing's arrived yet. And got := <-alerts doesn't need a nudge either: it's a plain blocking receive, so the bubble does exactly what it's designed to do: since the only thing anyone in the bubble is waiting on is that timer, fake time jumps straight to the moment it fires, wakes the time.AfterFunc goroutine, and unblocks our receive.
Run this with -race, repeatedly. It stays green: there's no shared memory left to race over.
Compare this to the SpyBlindAlerter approach from the earlier chapter. That's still a perfectly good tool for a different job: it checks what gets scheduled (for a given player count, are the right amounts scheduled at the right offsets?) without caring about real timing at all (useful when you're testing arithmetic, not timing). synctest didn't replace that need; it fills the coverage gap that a pure "what was I asked to do" spy always leaves behind: does the scheduling mechanism itself actually work?
Wrapping up
What we've covered
synctest.Testand the idea of a bubble with an isolated fake clock, one that only advances when something durably blocks, not on its own."Durably blocked": the condition that lets fake time advance, and why
sync.Mutexdeliberately doesn't count (but a channel receive does).Sleeping "long enough" is not the same as synchronizing: even a generous, always-in-practice-correct sleep is still a real data race if nothing enforces the ordering.
synctest.Wait()fixes that for a single check, but calling it with nothing else in the bubble to bound it can let the fake clock run further than you intended, which is exactly what happened testing the "nothing has happened yet" case.A test surfacing a design problem, not just a bug, and fixing the design instead of reaching for a lock.
Gotchas to watch for
If a goroutine in the bubble is still durably blocked when the bubble's root function returns,
synctest.Testfails the test as a deadlock rather than hanging; make sure background goroutines actually finish.Channels, timers, and tickers are tied to the bubble they were created in; using one from outside its bubble panics.
Real network and file I/O are not durably blocking, so you can't drive them through
synctest's fake clock directly; reach for something likenet.Pipeif you need an in-memory stand-in.time.AfterFunc's callback runs in a goroutine of its own, with none of the synchronization guarantees a channel gives you. If it has to touch shared state directly, that state needs its own lock, same as any other concurrent code.
Additional material
A note on how this chapter was written
This is the first chapter in the book written with AI assistance (Claude). I want to be upfront about that, and about what "assistance" actually meant here, because it wasn't "describe a chapter, get a chapter".
The process looked a lot like the TDD loop this book has been teaching the whole way through: research the real testing/synctest documentation and source rather than guessing, spike small throwaway programs to check claims before writing a single word of prose, and treat every claim from the docs as something to verify with a real go test -race run, not trust outright. Several of the gotchas in this chapter, the Wait() and race detector interaction chief among them, only exist here because a test genuinely failed in a way that wasn't expected, over several rounds of actually running it, and the reason had to be dug into before deciding what to write.
The design itself changed shape partway through, too. The first draft had StdOutAlerter writing straight into an io.Writer, which is exactly the kind of thing this book has always pushed back on when a test starts hurting: if your tests are causing you pain, listen to that signal and think about the design of your code. So we did, and ended up with the channel-based version above, and a shorter, better chapter for it.
Everything here was reviewed and edited, and pushed back on more than once: when the tone didn't sound right, when a section had ballooned past what the actual lesson warranted, when an explanation reached for jargon where a shown, real test failure would do a better job. If something in here still reads oddly, that's on me, not the tool.
Last updated