Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Go

Tags:
  • go
  • testing
  • synctest
  • concurrency
  • goroutines
  • timers

Go's testing/synctest: Testing Concurrent Code Without Sleeping in Tests

Every Go codebase with any concurrency in it eventually grows a test that looks like this:

d.Trigger()
time.Sleep(150 * time.Millisecond)
if calls.Load() != 1 {
    t.Fatal("debounce did not fire")
}

It works, mostly. On your laptop. On a loaded CI runner it occasionally doesn't, so someone bumps the sleep to 300ms "to be safe", and now the test suite is a few seconds slower and still fails once a month for no reason anyone can reproduce locally. Multiply that pattern across a few dozen tests for retries, debounces, rate limits and timeouts, and you've got a test suite that's both slow and untrustworthy, which is a genuinely annoying combination.

testing/synctest, stable since Go 1.25, is the stdlib's answer: run the concurrent code against a fake clock inside an isolated "bubble", and let the runtime itself decide when time should advance, rather than the test author guessing at a sleep duration that's hopefully long enough.

The bubble

The entry point is synctest.Test:

func Test(t *testing.T, f func(t *testing.T))

f runs inside a new bubble: an isolated scheduling domain with its own fake clock that starts at midnight UTC on 1 January 2000. Any goroutine spawned from within f is part of the same bubble. Inside the bubble, time.Now, time.Sleep, timers and tickers all use the fake clock instead of the real one, with no changes needed to the code under test. You don't need to inject a clock interface or wrap time.Now behind a seam just to make something testable, which used to be the standard workaround (there are whole small packages on pkg.go.dev whose entire job is providing a fake-clock interface for exactly this reason).

The clock only advances when every goroutine in the bubble is "durably blocked", meaning it can only be woken by something else inside the same bubble. When that happens, the runtime fast-forwards straight to the next scheduled timer event rather than idling. So a test that sleeps for an hour of fake time still runs in a few microseconds of real time.

synctest.Wait() is the other half of the API: it blocks the calling goroutine until every other goroutine in the bubble has reached that durably-blocked state. It's how you assert "background work has definitely settled" without a race.

A debouncer, tested without waiting

Here's a small debouncer, the kind of thing you'd use to coalesce a burst of filesystem-change events into a single reload:

package debounce

import (
	"sync"
	"time"
)

type Debouncer struct {
	mu    sync.Mutex
	timer *time.Timer
	delay time.Duration
	fn    func()
}

func New(delay time.Duration, fn func()) *Debouncer {
	return &Debouncer{delay: delay, fn: fn}
}

func (d *Debouncer) Trigger() {
	d.mu.Lock()
	defer d.mu.Unlock()
	if d.timer != nil {
		d.timer.Stop()
	}
	d.timer = time.AfterFunc(d.delay, d.fn)
}

The behaviour worth testing is: a Trigger call resets the delay, so a steady stream of triggers arriving faster than the delay should never fire the callback, and it should fire exactly once after things go quiet. That's precisely the kind of test that's miserable to write with real sleeps, because you need several distinct waits with different timings and every one of them is a chance for CI jitter to cause a false failure.

package debounce

import (
	"sync/atomic"
	"testing"
	"testing/synctest"
	"time"
)

func TestDebouncer(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		var calls atomic.Int32
		d := New(100*time.Millisecond, func() { calls.Add(1) })

		d.Trigger()
		time.Sleep(50 * time.Millisecond)
		d.Trigger() // resets the 100ms delay
		time.Sleep(50 * time.Millisecond)
		synctest.Wait()

		if got := calls.Load(); got != 0 {
			t.Fatalf("fired early: got %d calls, want 0", got)
		}

		time.Sleep(60 * time.Millisecond)
		synctest.Wait()

		if got := calls.Load(); got != 1 {
			t.Fatalf("got %d calls, want 1", got)
		}
	})
}

The two Trigger calls are 50ms apart, so the first timer never gets a chance to fire; only the second one, 100ms after the last trigger, does. The test asserts that with fake-clock precision and zero real elapsed time worth mentioning, and it'll pass exactly as reliably on a busy CI box as it does on your desk, because there's no real scheduler jitter involved at any point. You could change the delay to an hour and the test would still run instantly.

There's also a synctest.Sleep(d) helper that's just time.Sleep(d) followed by an automatic Wait(), for the common case where you want both in sequence.

What "durably blocked" actually covers

This matters because it decides what your test can rely on. Durably blocking operations, the ones that count towards the bubble going idle and the clock advancing, are:

  • channel send, receive and select, on channels created inside the bubble
  • time.Sleep, and timers/tickers created inside the bubble
  • sync.WaitGroup.Wait
  • sync.Cond.Wait

Deliberately excluded is sync.Mutex.Lock. A goroutine outside the bubble could in principle be holding that mutex, so the runtime can't treat "blocked on Lock" as something only bubble-internal code can resolve, and doesn't try. If your test's quiescence depends on a mutex-protected code path settling, Wait() won't wait for it; you'll want a channel or a WaitGroup for that synchronisation instead.

Real network I/O and blocking syscalls are excluded for the same reason and a practical one besides: "goroutines blocked on network I/O prevent a bubble from becoming idle" rather than counting as durably blocked, so a goroutine stuck on a genuine socket read just sits there rather than letting the clock advance. If you're testing something built on net/http, use net.Pipe or an in-memory transport rather than a real httptest.Server socket if you want it to cooperate with the fake clock.

Deadlocks become test failures instead of hangs

The flip side of the durably-blocked rule is genuinely useful: if every goroutine in the bubble ends up durably blocked and there's no pending timer left to advance to, that's an actual deadlock, and Test panics immediately rather than letting the test hang until CI kills it after ten minutes. The panic message tells you exactly which goroutines were stuck and where. A test that would previously have needed a timeout wrapper to fail cleanly now just fails, fast, with a stack trace pointing at the real problem.

The same mechanism catches goroutine leaks: Test waits for every goroutine spawned inside the bubble to exit before it returns, so a background goroutine that never terminates shows up as a deadlock panic rather than silently leaking for the rest of the test binary's life.

One sharp edge worth knowing about before you hit it in a stack trace: a bubbled channel, timer, ticker, WaitGroup or Cond can't safely cross the bubble boundary. Operating on a bubbled channel, timer or ticker from a goroutine outside the bubble panics outright; touching a bubbled WaitGroup or waking a bubbled Cond.Wait from outside is a fatal error. In practice this means: don't hand a channel created inside synctest.Test off to some long-lived background goroutine that was started before the test began.

If you're still on Go 1.24, the package existed behind GOEXPERIMENT=synctest with a slightly different shape: synctest.Run(f func()) instead of Test, and no automatic association between a bubble panic and the enclosing *testing.T. Run is now deprecated in favour of Test, which is the one worth reaching for in anything currently being written.