skip to content

Deterministic Time with synctest

Inside a synctest bubble time is fake and synctest.Wait blocks until every goroutine there is durably blocked, so a one-hour timeout test finishes instantly. It replaces sleep-and-hope.

part ofGo (Golang)overview, primer and where to startread it →
on this pageshow

questions

4

What does testing/synctest's Test function give a Go test that real time.Sleep calls cannot?

level: juniorimportance: should knowfreq 26%

answer

  1. the test body runs inside a bubble
  2. wall-clock time is not what advances
  3. the clock jumps to the next timer
  4. a five-minute TTL in microseconds

basics

~20 s

synctest.Test runs a test inside a bubble with a fake clock, so time.Sleep, timers and tickers jump forward instantly instead of really waiting. A five-minute expiry can be tested in microseconds, with no slow-machine flakes.

solid answer

~40 s

`synctest.Test(t, f)` runs `f` in a bubble: the goroutine running `f` plus every goroutine started from inside it. Within that bubble the `time` package is virtualised — `time.Now`, `time.Sleep`, timers, tickers and the deadlines of contexts created inside all read a fake clock. That clock only moves when every goroutine in the bubble is blocked in a way that only another bubbled goroutine can undo, and then it jumps straight to the earliest pending timer. So a test can sleep for a virtual hour and finish in microseconds, and the sequence of events is exactly the one the code would see in production. A real `time.Sleep` in a test buys neither: it costs wall-clock time and still fails on a loaded CI machine. The package is generally available from Go 1.25.

code

go · 16 lines
go
func TestCacheEntryExpires(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		c := newCache(5 * time.Minute)
		c.Set("k", "v")

		time.Sleep(4 * time.Minute)
		if _, ok := c.Get("k"); !ok {
			t.Fatal("entry expired early")
		}

		time.Sleep(2 * time.Minute)
		if _, ok := c.Get("k"); ok {
			t.Fatal("entry outlived its TTL")
		}
	})
}

go deeper

for a junior

Be ready to say what synctest.Test does in one sentence: it runs the test body in a bubble with a fake clock, so sleeps and timers cost no real time. Naming one thing it makes testable, such as an expiry, is enough here.

for a middle

An interviewer expects the mechanism: which goroutines join the bubble, which time calls are virtualised, and the rule that the clock only advances when every bubbled goroutine is blocked on something inside the bubble.

for a senior

Show where the technique stops. Real I/O and pre-existing goroutines are not bubbled, the bubble controls time rather than interleaving, and a test that reaches outside it will hang rather than run fast.

for a principal

Be ready to argue what the team gains by adopting it: time-driven tests stop being a source of flakes and stop paying wall-clock cost, at the price of a Go 1.25 floor and of keeping test dependencies in-memory.

## The problem this solves Go code is full of durations. A cache entry expires after five minutes. A refresh ticker fires every thirty seconds. A retry backs off for two seconds, then four. Testing that behaviour honestly means letting time pass, and a suite that lets real time pass is both slow and unreliable: sleep a little and a loaded CI box fails the assertion; sleep a lot and the suite takes minutes to run. `testing/synctest`, generally available since Go 1.25, deletes the waiting rather than tuning it. ## What a bubble is `synctest.Test(t, f)` runs `f` in a *bubble*. The bubble consists of the goroutine running `f` and every goroutine started from inside it, directly or transitively. Membership is by ancestry: a goroutine that already existed before the call is not in the bubble, and neither is anything else the process is doing. Inside the bubble the `time` package is replaced by a virtual implementation. `time.Now`, `time.Since`, `time.Sleep`, `time.After`, `time.NewTimer`, `time.NewTicker`, `time.AfterFunc`, and the deadlines of `context.WithTimeout` / `context.WithDeadline` values created inside the bubble all read the bubble's own clock. That clock starts at a fixed instant and is driven by the runtime, not by the machine. ## How the clock moves The rule is short and it is the whole model: **the bubble's clock advances only when every goroutine in the bubble is durably blocked, and it then jumps directly to the earliest pending timer deadline.** "Durably blocked" means blocked such that only another goroutine in the same bubble could unblock it — sleeping, waiting on a channel created inside the bubble, waiting in `sync.WaitGroup.Wait` or `sync.Cond.Wait`. When nothing in the bubble can make progress and a timer is outstanding, waiting is pointless, so the runtime simply moves the clock to that timer and lets it fire. The consequence for a test author is that `time.Sleep(24 * time.Hour)` returns essentially at once, while every observation the program makes is consistent with a day having passed: `time.Now` differences, expiry comparisons, and every ticker tick that would have occurred in between. ## What it looks like in practice A test for a cache whose entries live five minutes can be written as the story it actually is: put a value in, sleep four minutes, assert it is still there, sleep two more, assert it is gone. No padding, no scaling constant, no `-short` skip. It runs faster than a test that sleeps ten milliseconds "to be safe", and it is deterministic: there is no wall clock left to be slow. ## What it does not do It does not fake the CPU. Computation inside the bubble takes the real time it takes; only the *clock the program reads* is virtual. It does not virtualise the outside world. Real network reads, file I/O, syscalls and channels created outside the bubble are still real, and a goroutine sitting in one of them is not durably blocked — so the bubble never settles and the clock never advances. Tests written for a bubble keep their dependencies inside it, using in-memory transports and channels created within `f`. It does not make concurrency bugs disappear. The bubble controls *time*, not the interleaving of runnable goroutines; the race detector is still the tool for races. And the bubble has an exit contract: every goroutine started inside it must have exited when `f` returns, or `synctest.Test` panics rather than letting a stray goroutine escape. ## Versions Go 1.24 shipped an experimental version of the package, entered through `synctest.Run(f func())` and gated behind `GOEXPERIMENT=synctest`. Go 1.25 made the package generally available with the `synctest.Test(t, f)` entry point, which ties the bubble to a `*testing.T`. Go 1.27 added `synctest.Sleep` and an in-memory `net/http/httptest.NewTestServer` intended for bubbled tests. If your module builds on Go 1.25 or later you can rely on `synctest.Test`. ## Why interviewers like this question It separates candidates who treat `time.Sleep` as a synchronisation primitive from those who see time as an input a test should control. The follow-up is almost always "and what stops the bubble from advancing?", which is where the durable-blocking rule has to come out.

  • Which time-related operations inside the bubble actually use the fake clock?
    `time.Now`, `time.Since`, `time.Sleep`, `time.After`, `time.NewTimer`, `time.NewTicker` and `time.AfterFunc`, plus the deadlines of contexts created inside the bubble. Code running outside the bubble keeps reading the real clock, which is one reason a bubbled test should not share timers with goroutines started before the call.
  • Which Go version can you rely on for synctest.Test, and what came before it?
    Go 1.25 made `testing/synctest` generally available with `synctest.Test(t, f)`. Go 1.24 had an experimental predecessor, `synctest.Run(f func())`, gated behind `GOEXPERIMENT=synctest`. Go 1.27 added `synctest.Sleep`. On anything older there is no bubble at all.
  • Does the bubble make the test's computation faster too?
    No. Only the clock the program reads is virtual; CPU work takes the time it takes. What disappears is idle waiting — sleeps, timer deadlines and ticker periods — which is where the minutes in a time-driven suite normally go.

It is a flight simulator for time: the program experiences a full six-hour flight — every timer, every expiry, in the right order — while the people running the test never wait six hours.

saying these in an interview costs you the question

  • Says synctest speeds tests up by shrinking durations by a fixed factor
  • Thinks the fake clock ticks on its own in the background
  • Claims you still need a small padding sleep for reliability
  • Believes goroutines started before the call also see fake time
  • Assumes the bubble also makes computation inside it faster
open as a page

When does synctest.Wait return, and which blocked goroutines count as durably blocked?

level: middleimportance: should knowfreq 38%

basics

~20 s

synctest.Wait returns once every other goroutine in the caller's bubble is durably blocked: blocked so that only another goroutine in that same bubble could unblock it, such as a bubble-created channel operation, time.Sleep, WaitGroup.Wait or Cond.Wait.

open as a page

Why does synctest.Test panic when a goroutine started inside the bubble is still alive at the end?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Because the bubble owns every goroutine started inside it and must be empty when the test function returns. A sweeper or ticker loop nobody stopped cannot be left running on a fake clock, so synctest.Test panics instead of passing quietly.

open as a page

Why would a test using testing/synctest hang until the test timeout instead of its clock advancing?

level: seniorimportance: nice to knowfreq 22%

basics

~20 s

Because a goroutine in the bubble is blocked on something outside it — real I/O or a channel created before the bubble — so it is never durably blocked, the bubble never settles, and the fake clock will not move.

open as a page