Testing Concurrent Code Without the Flakes
How to write a test that actually exercises goroutines, why running the suite under go test -race is the real safety net, and how testing/synctest gives you a fake clock and a deterministic bubble so time-based concurrent code runs instantly instead of flaking. Compiled and run against Go 1.26.5.
This series has told you again and again to run your code under -race. What it has not shown you is the other half of that advice: how to write a test that puts your goroutines through their paces in the first place, so the detector has something real to watch. A race detector only sees code that runs, and a test suite that never launches two goroutines at the same address gives it nothing to find. This chapter closes that gap. We will write a concurrent test properly, wire -race into it, and then meet testing/synctest, the tool that finally makes time-based concurrent code testable without sleeps and luck. Everything below was compiled and run against Go 1.26.5.
A test that actually exercises goroutines
Start with a goroutine-safe counter, the same shape as the mutex chapter’s fix: a sync.Mutex guarding an int, with Add and Value methods that both take the lock.
The test’s job is to hammer it from many goroutines and assert the arithmetic still adds up. The idiom is the one you already know: launch with wg.Go, block on wg.Wait, then check the result. A table lets one test body cover several concurrency shapes at once.
func TestCounterConcurrent(t *testing.T) {
cases := []struct {
name string
goroutines int
perG int
}{
{"ten by ten", 10, 10},
{"hundred by one", 100, 1},
{"one by thousand", 1, 1000},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var c Counter
var wg sync.WaitGroup
for range tc.goroutines {
wg.Go(func() {
for range tc.perG {
c.Add(1)
}
})
}
wg.Wait()
want := tc.goroutines * tc.perG
if got := c.Value(); got != want {
t.Errorf("Value() = %d, want %d", got, want)
}
})
}
}
Each case fans out goroutines writers, each adding 1 a total of perG times, and asserts the final value is the product. The wg.Wait() is load-bearing: without it the test would read c.Value() while the writers are still running, and you would assert against a half-finished count. That is the first rule of testing goroutines. The test goroutine must synchronize with the ones it started before it inspects the result, and a WaitGroup is how you draw that line.
Here is the sharp edge, though. This test passes. It also passes if you delete the mutex from Counter and leave a raw count++ in Add. Try it and most runs still print the right total, because with a lock-free counter the lost updates are just infrequent enough to slip past a single run. A green concurrent test proves almost nothing on its own. It tells you the happy path ran once without a visible wrong answer, which is exactly the false comfort the mutex chapter warned about. The test is necessary. It is nowhere near sufficient.
The safety net: go test -race
What makes the test worth writing is the flag you run it under. go test -race builds the suite with the race detector’s instrumentation, then watches every memory access your goroutines make during the run. The counter test drives real concurrent access to c.n, so with the mutex removed the detector catches the unsynchronized read and write on the spot, prints its WARNING: DATA RACE, and fails the test with a nonzero exit. With the mutex in place the same run is clean, because the lock supplies the happens-before edge the detector was looking for.
$ go test -race -v
=== RUN TestCounterConcurrent
=== RUN TestCounterConcurrent/ten_by_ten
=== RUN TestCounterConcurrent/hundred_by_one
=== RUN TestCounterConcurrent/one_by_thousand
--- PASS: TestCounterConcurrent (0.00s)
--- PASS: TestCounterConcurrent/ten_by_ten (0.00s)
--- PASS: TestCounterConcurrent/hundred_by_one (0.00s)
--- PASS: TestCounterConcurrent/one_by_thousand (0.00s)
PASS
ok example.com/counter 1.378s
This is the pairing to internalize. The test’s coverage decides what concurrent code executes; -race decides whether any of that execution was unsafe. Neither works without the other. A thorough suite run without -race catches wrong answers but sleeps through the timing bugs that produce them intermittently, and -race run against a suite that never exercises concurrency has nothing to instrument. So the standing advice from the mutex chapter is really an instruction about your test command: run go test -race ./... on a suite whose tests actually launch goroutines, and put that command in CI so a race turns the build red instead of shipping. The instrumented build costs some speed and memory, which is why most teams run it in CI rather than on every local go test.
The real problem: testing code that waits
Counters are the easy case, because the assertion does not depend on time. The moment your code involves a timer, a timeout, or a background goroutine that fires later, testing gets genuinely hard. Consider a cache whose entry expires after a time-to-live: New stores a value and starts a goroutine that sleeps for the TTL and then marks the entry stale.
How do you test that it expires? The obvious approach is to give it a short TTL, time.Sleep a little longer in the test, and then check. That test is slow, because a real fifty-millisecond wait is fifty milliseconds you pay on every run. Worse, it is flaky: if the machine is loaded and the expiry goroutine has not been scheduled by the time your sleep ends, the assertion fails for no reason at all. You end up padding the sleep to cover the slowest plausible scheduling delay, which makes every run slower to make failures rarer, and it never fully works. This is the tax that has made concurrent, time-based code notoriously untestable.
testing/synctest: a bubble with a fake clock
testing/synctest, stable since Go 1.25 and present in 1.26.5, removes that tax. You wrap your test body in synctest.Test(t, func(t *testing.T){ ... }), and everything inside runs in an isolated bubble. Every goroutine you start within the bubble belongs to it, and inside the bubble the time package uses a fake clock that starts at midnight UTC on 2000-01-01. The trick is how that clock advances: it only moves when every goroutine in the bubble is durably blocked, meaning blocked on something only another bubble goroutine could unblock, such as a channel op or a time.Sleep. When they are all parked, the clock jumps straight to the next moment that will wake someone. A time.Sleep(5 * time.Second) does not wait five seconds. It parks the goroutine, the clock leaps forward five logical seconds instantly, and the goroutine resumes.
The second tool is synctest.Wait, which blocks the calling goroutine until every other goroutine in the bubble is durably blocked. That is how you order your assertion after the background work without a sleep or a channel: call Wait, and you are guaranteed the expiry goroutine has reached its next blocking point (or exited) before you read.
Here is the cache’s expiry test. The API is exactly two calls: synctest.Test to enter the bubble, synctest.Wait to settle it.
func TestExpiry(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now() // fake clock: midnight UTC 2000-01-01
c := New("catalog-v3", 5*time.Second)
if v, ok := c.Get(); !ok || v != "catalog-v3" {
t.Fatalf("right away: Get() = (%q, %v), want fresh", v, ok)
}
// One second short of the TTL, still fresh. Instant under synctest.
time.Sleep(4 * time.Second)
synctest.Wait()
if _, ok := c.Get(); !ok {
t.Fatal("at t=4s: value expired early")
}
// Cross the TTL. Wait lets the expiry goroutine's write land first.
time.Sleep(1 * time.Second)
synctest.Wait()
if _, ok := c.Get(); ok {
t.Fatal("at t=5s: value did not expire")
}
t.Logf("logical time elapsed inside the bubble: %v", time.Since(start))
})
}
Read what this asserts. At t=4s the value is still fresh; at t=5s it is gone. That is the real contract of the TTL, tested at the exact boundary, and it runs deterministically every single time because the fake clock removes all the scheduling slop. The synctest.Wait after crossing the TTL matters: the root goroutine and the expiry goroutine both wake at the same logical instant, so without Wait you could read c.Get() before the expiry goroutine’s write lands. Wait guarantees that goroutine has finished before the assertion.
$ go test -v
=== RUN TestExpiry
cache_test.go:35: logical time elapsed inside the bubble: 5s
--- PASS: TestExpiry (0.00s)
PASS
ok example.com/cache 0.331s
Look at the two numbers. The test measured 5s of logical time elapsed inside the bubble, and the run took 0.00s of real time. That is the whole pitch in one line of output. Five seconds of clock behavior, tested exactly, in no wall-clock time and with zero flakiness. The old sleep-and-hope version would have taken five real seconds and still failed occasionally on a busy machine.
A few edges are worth knowing before you reach for it. Locking a sync.Mutex is not durably blocking, nor is real network or disk I/O, so a bubble that hangs on those cannot settle; the synctest docs steer you toward in-memory fakes like net.Pipe for network code. And a WaitGroup only associates with the bubble once you call Add or Go from inside it, so keep your concurrency primitives local to the test body.
Flushing out the flakes you can’t bubble
Not every test fits in a bubble, and for the rest there is a blunter tool. A test that passes once may still harbor a race that only shows under a particular interleaving. go test -race -count=20 ./... runs the suite twenty times with the detector on, giving the scheduler twenty fresh chances to hit the bad interleaving. If a concurrent test passes a hundred runs under -race but fails one, you have not found bad luck. You have found a real bug that is rare, and rare is the most dangerous kind, because it is the one that waits until production to appear.
Final thoughts
Testing concurrent code comes down to three moves. Write tests that genuinely launch goroutines and synchronize with them through a WaitGroup before asserting, because a test that never runs concurrent code proves nothing about it. Run that suite under go test -race, in CI, because coverage decides what the detector can see and the detector decides whether it was safe. And for anything involving timers, timeouts, or delayed goroutines, reach for testing/synctest: wrap the body in synctest.Test, let the fake clock collapse every time.Sleep to nothing, and use synctest.Wait to order your assertions deterministically. A green concurrent test is only worth what it was run under. Give it goroutines to exercise, -race to police them, and a bubble to make time behave, and “flaky concurrency test” stops being a phrase you have to accept.
Next: the Go memory model — the rules that decide when one goroutine is actually guaranteed to see another’s writes.
Comments