🕵️ SicherheitslückenWhat continuous operational resilience looks like under DORA(09.09.2026 um 17:53 Uhr)
🔧 AI Nachrichten OpenAI seeks tougher AI rules. CIOs may feel the ripple effects(10.09.2026 um 12:11 Uhr)
🔧 AI Nachrichten Mistral valued at €21bn after €3bn Series D funding round(08.09.2026 um 10:19 Uhr)
🪟 Windows TippsWindows XP's Cursor Indicator Is Getting a Windows 11 Refresh(25.08.2026 um 13:00 Uhr)
🕵️ SicherheitslückenWhat continuous operational resilience looks like under DORA(09.09.2026 um 17:53 Uhr)
🔧 AI Nachrichten OpenAI seeks tougher AI rules. CIOs may feel the ripple effects(10.09.2026 um 12:11 Uhr)
🔧 AI Nachrichten Mistral valued at €21bn after €3bn Series D funding round(08.09.2026 um 10:19 Uhr)
🪟 Windows TippsWindows XP's Cursor Indicator Is Getting a Windows 11 Refresh(25.08.2026 um 13:00 Uhr)

🔧 Programmierung 🕛 vor 1 Monat 5 Min Lesezeit
0

Go 1.25 testing/synctest: No More Flaky Concurrent Tests

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht

Three green tests locally, two red on CI. The outcome depends on how busy the build server is that day. This isn't a logic bug — it's a time.Sleep that assumes 100 ms is always enough for a goroutine to finish.



That test doesn't verify anything. It gambles.



testing/synctest, stable since Go 1.25 (August 2025), solves this at the root. No more guesswork, no more arbitrary waits: the clock advances on demand, inside an isolated bubble.






The test that lies



Debounce is a classic example of time-dependent concurrent code: trigger an action only if no call has happened in the last N milliseconds. Here's a simple implementation.




CODE
func Debounce(fn func(), delay time.Duration) func() {
var timer *time.Timer
var mu sync.Mutex
return func() {
mu.Lock()
defer mu.Unlock()
if timer != nil {
timer.Stop()
}
timer = time.AfterFunc(delay, fn)
}
}






And the naive test that goes with it:




CODE
// ✗ Fragile
func TestDebounce(t *testing.T) {
called := 0
debounced := Debounce(func() { called++ }, 50*time.Millisecond)

debounced()
debounced()
debounced()

time.Sleep(200 * time.Millisecond) // fingers crossed

if called != 1 {
t.Fatalf("want 1 call, got %d", called)
}
}






This passes locally because your machine is fast and 200 ms feels like plenty. On a loaded CI runner, the time.AfterFunc goroutine may not have had a chance to run by the time called is read. Flaky. No error message explains why.



go test -race won't catch this either. There's no concurrent memory access at the same instant — the problem is purely temporal. Two different tools, two different failure modes.






What testing/synctest does



testing/synctest runs your test inside a "bubble". Inside the bubble, the time package uses a fake clock controlled by the test. That clock doesn't tick on its own: it only advances when all goroutines in the bubble are durably blocked — waiting on a channel, a mutex, a timer — but not on a syscall or network I/O.



The Go 1.25 API has exactly two functions:




  • synctest.Test(t, f) — runs f in a new bubble and waits for every goroutine in that bubble to exit before returning to the test framework.

  • synctest.Wait() — blocks until all other goroutines in the bubble are durably blocked. The explicit synchronization point: "background work is done for now."



Go 1.24 had an experimental preview behind GOEXPERIMENT=synctest with a slightly different API (synctest.Run() instead of synctest.Test()). Go 1.25 graduated the package to the standard library and finalized the API. No flags, no build tags — direct import.






Before and after



The same debounce test, rewritten with testing/synctest:




CODE
// ✓ Deterministic
import "testing/synctest"

func TestDebounce(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
called := 0
debounced := Debounce(func() { called++ }, 50*time.Millisecond)

debounced()
debounced()
debounced()

// Advances the virtual clock by 100 ms instantly.
// The AfterFunc goroutine (fires at 50 ms) wakes up along the way.
time.Sleep(100 * time.Millisecond)
synctest.Wait()

if called != 1 {
t.Fatalf("want 1 call, got %d", called)
}
})
}






What happens inside the bubble:




  1. The three debounced() calls each schedule a time.AfterFunc at 50 ms, canceling the previous one.

  2. time.Sleep(100ms) blocks the test goroutine. All goroutines in the bubble are now durably blocked, so the runtime advances the virtual clock to 50 ms. The AfterFunc goroutine wakes up and runs called++.

  3. The clock continues to 100 ms. The test goroutine wakes up. synctest.Wait() confirms the AfterFunc goroutine has finished.

  4. The assertion on called is reliable. Always. No gambling.



Actual wall-clock time for the test: microseconds, not 200 ms.






What synctest doesn't cover



testing/synctest isn't a silver bullet. Three categories slip through.



Network I/O. A goroutine blocked on a network read isn't "durably blocked" in synctest's terms — an external process can unblock it at any time. If your code makes real HTTP calls, synctest.Wait() can't reason about its state. Fix: replace the network layer with an interface backed by a deterministic test implementation (net.Pipe(), httptest.Server, or a hand-rolled mock).



Syscalls and cgo. Same reason: the Go runtime doesn't control what the OS or C code does in the background. These calls are not considered durably blocking.



Package-level sync.WaitGroup (var wg sync.WaitGroup). These can't be associated with a bubble. Declare them inside the test function instead.



Ideal use cases: timers, tickers, debounce, rate limiters, TTL caches, retry with backoff — anything that chains time.After, time.Sleep, or time.AfterFunc with goroutines communicating over channels.






Conclusion



go test -race remains the right tool for real data races. testing/synctest targets a different class of failure: tests whose reliability depends on wall-clock timing. The two tools are orthogonal — use both.



If you have time.Sleep in your Go tests to "give the goroutine time to finish," that's the signal. testing/synctest will do the job faster and correctly. Go 1.25 has been out since August 2025 — no flags, no configuration, just import it.



📚 Related reading. If goroutines that never exit are your problem, the natural follow-up is Goroutine leaks in Go: detect, understand, fix.

Vollständiger Original-Bericht
Ausführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
1 Quelle
Sam Altman calls GPT-6 Astra rollout ‘messy’ as enterprise users wait for access
1 Quelle
Swiss government explores replacing Microsoft 365 with open-source software
1 Quelle
What continuous operational resilience looks like under DORA