- Golang Nugget
- Posts
- Testing concurrent code with synctest
Testing concurrent code with synctest
Go 1.24’s synctest makes testing goroutines easy. No more sleep hacks—just reliable concurrency tests

Warm-up Time
📺️ Go 1.24 Just RELEASED – Weak Pointers, Generic Aliases & More! — 5 minutes video breaking down the Go 1.24 key changes including Generic Type Aliases, Weak Pointers, AddCleanup, Swiss Table and few more changes.
🛠️ 11 tips for structuring your Go projects — How you should structure your Go project? Well, there’s no right answer for that and it always depends. Although, Alex wrote a good article sharing some tips on finding/scafolding what works for you the best.
🤖 Building RAG systems in Go with Ent, Atlas, and pgvector —A hands-on blog post about building a RAG (Retrieval-Augmented Generation) system using Ent, Atlas, and pgvector. RAG enables context-aware prompting by retrieving relevant data. This post is a great starting point for becoming familiar with Ent and pgvector.
📦 How Protobuf Works—The Art of Data Encoding — Protobuf is an efficient, language-agnostic data serialisation mechanism. It produces much smaller output with the cost of not being human-readable. It has 4 wire types (actually 5, but one of them is deprecated) to encode data. This post by VictoriaMetrics is a great deep dive into understanding on-wire format of Protobuf.
🔍 Map internals in Go 1.24 — Go Map has gone through a complete rewrite and the new version is based on Swiss Table. This post describes how Swiss Table works under the hood. (If you like me to write a deep dive about how it used to work and compare it to the new Go, reply Swiss Table)

Tool Time
🚀 hugocarreira/easycache — High-performance, in-memory caching library for Go, supporting multiple eviction policies like FIFO, LRU, LFU, and TTL-based expiration. It is thread-safe, lightweight, and provides built-in metrics.
🌐 yaitoo/xun — Xun (pronounced 'shoon') is a web framework built on Go's built-in html/template and net/http package’s router.
🔄 pancsta/asyncmachine-go — Declarative control flow library. The main purpose is workflow, but it can also be used for developing other stateful applications.
📚️ ent/ent — A simple, yet powerful entity framework (Kinda like ORM) for Go. It let you to model and query database as a graph structure.
☔ cenkalti/rain — BitTorrent client and library in Go
🔍 open-telemetry/opentelemetry-go-instrumentation — Provides OpenTelemetry tracing instrumentation for Go libraries using eBPF.
(I did a deep dive on compile-time instrumentation—check it out!)

And now from our sponsor, Superhuman AI!
Start learning AI in 2025
Keeping up with AI is hard – we get it!
That’s why over 1M professionals read Superhuman AI to stay ahead.
Get daily AI news, tools, and tutorials
Learn new AI skills you can use at work in 3 mins a day
Become 10X more productive

Deep Dive
Go 1.24 comes with an experimental synctest
package to support testing concurrent code. If you’re dealing with goroutines and timing issues in your tests, this package might just be what you need. As it’s experimental, you need to compile your code by GOEXPERIMENT=synctest
.
The package is simple, only two methods, synctest.Run
and synctest.Wait
.
Run
: This wraps your test code in a special “bubble” – an isolated zone where all the goroutines you start are managed together. The bubble ends when every goroutine inside it exits. If something gets stuck (that is, durably blocked),Run
will panic.Wait
: This function pauses the test until every goroutine in the bubble is blocked. It’s like saying, “Hold on until all my concurrent operations have either finished or are waiting for something else.”
Let’s See It in Action
Testing a Function Called After a Context Cancellation
Imagine you have a function that should only be called after a context is cancelled. Without synctest
, you might need to use channels and timers to check this. Here’s how it looks with synctest:
func TestAfterFunc(t *testing.T) {
synctest.Run(func() {
ctx, cancel := context.WithCancel(context.Background())
funcCalled := false
context.AfterFunc(ctx, func() {
funcCalled = true
})
// Wait for all goroutines to be blocked
synctest.Wait()
if funcCalled {
t.Fatalf("AfterFunc function called before context is canceled")
}
cancel()
synctest.Wait()
if !funcCalled {
t.Fatalf("AfterFunc function not called after context is canceled")
}
})
}
Here, synctest.Wait
makes sure we know exactly when the AfterFunc
goroutine has either run or isn’t going to run until we cancel the context. No guessing games!
Testing Code That Depends on Time
Testing time-dependent behaviour (like timeouts) can be a real pain. With synctest
, you get a fake clock inside the bubble. Here’s an example that tests a context timeout:
func TestWithTimeout(t *testing.T) {
synctest.Run(func() {
const timeout = 5 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// Wait just a bit before the timeout.
time.Sleep(timeout - time.Nanosecond)
synctest.Wait()
if err := ctx.Err(); err != nil {
t.Fatalf("before timeout, ctx.Err() = %v; want nil", err)
}
// Now wait until the timeout passes.
time.Sleep(time.Nanosecond)
synctest.Wait()
if err := ctx.Err(); err != context.DeadlineExceeded {
t.Fatalf("after timeout, ctx.Err() = %v; want DeadlineExceeded", err)
}
})
}
By calling time.Sleep
and then synctest.Wait
, you control the fake clock, ensuring the context expires exactly when you expect.
Durably Blocked – What’s the Deal?
Imagine you’re in a room full of people (or goroutines), and everyone is waiting on someone else inside the room to give them a signal. When that happens, we say the room (or "bubble") is durably blocked. In simple terms, it means every goroutine in your test is paused, and only something inside the bubble can wake them up.
If even one goroutine might get a nudge from outside the bubble—like a friend getting a text message—the whole group isn’t truly stuck.
Operations that durably block a goroutine:
A send or receive on a nil channel
A send or receive blocked on a channel created within the same bubble
A
select
statement where every case is durably blockingtime.Sleep
sync.Cond.Wait
sync.WaitGroup.Wait
Also, pay attention to the following:
Mutexes: Operations on a
sync.Mutex
are not durably blocking.Channels: Channels created within a bubble behave differently from those created outside. Channel operations are durably blocking only if the channel is bubbled (created in the bubble). Operating on a bubbled channel from outside the bubble panics.
I/O: External I/O operations, such as reading from a network connection, are not durably blocking.
Bubble lifetime: The
Run
function starts a goroutine in a new bubble. It returns when every goroutine in the bubble has exited. It panics if the bubble is durably blocked and cannot be unblocked by advancing time.
If you gave it a try, share your feedback at issue#67434.
How did you like this edition?Or hit reply and share your thoughts! |
Enjoyed reading? Pass it along to someone else who might! Sharing is caring.

Reply