Go

How does testing/synctest (Go 1.25) make concurrent, time-dependent code testable?

Question 182HardGo 1.22 to 1.25

synctest.Test(t, f) runs f in a new goroutine inside an isolated bubble. Goroutines started from it belong to the bubble, and the bubble has a fake clock that starts at 2000-01-01 00:00 UTC. Time moves forward only when every goroutine in the bubble is durably blocked, for example on a channel created in the bubble, time.Sleep, sync.Cond, sync.WaitGroup, or a select over bubble channels. The clock then jumps straight to the next timer. synctest.Wait() blocks until all other goroutines in the bubble are durably blocked.

func TestContextTimeout(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
		defer cancel()

		time.Sleep(5*time.Second - time.Nanosecond) // returns instantly
		synctest.Wait()
		if err := ctx.Err(); err != nil {
			t.Fatalf("expired too early: %v", err)
		}

		time.Sleep(time.Nanosecond)
		synctest.Wait()
		if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
			t.Fatal("not expired after deadline")
		}
	})
}

Key points:

  • The test runs in microseconds and is deterministic. There are no real sleeps and no flaky timing margins.
  • If all bubble goroutines are blocked and no timer can wake them, Test fails with a deadlock report. Test also waits for every bubble goroutine to exit, so leaks are caught.
  • Blocking on a mutex, on real I/O such as a network connection, or on a channel created outside the bubble is not durable. Use in-memory fakes such as net.Pipe for network code.
  • Go 1.24 shipped it as an experiment (GOEXPERIMENT=synctest, with synctest.Run). Go 1.25 made it generally available with synctest.Test, and Run is deprecated.

More on Goroutines & the Scheduler

All 35 Goroutines & the Scheduler questions