Go: Testing Concurrent Applications

Testing concurrent programs is a lot like testing single-task programs. If the code is well-designed, you can test the state of a concurrent program with standard tools like channels, wait groups, and other abstractions built on top of them.
But if you've made it so far, you know that concurrency is never that easy. In this chapter, we'll go over common testing problems and the solutions that Go offers.
Waiting for Goroutines to Finish
Let's say we want to test this function:
// Calc calculates something asynchronously.
func Calc() <-chan int {
out := make(chan int, 1)
go func() {
out <- 42
}()
return out
}
Calculations run asynchronously in a separate goroutine. However, the function returns a result channel, so this isn't a problem:
func Test(t *testing.T) {
got := <-Calc() // (X)
if got != 42 {
t.Errorf("got: %v; want: 42", got)
}
}
PASS
At point ⓧ, the test is guaranteed to wait for the inner goroutine to finish. The rest of the test code doesn't need to know anything about how concurrency works inside the Calc function. Overall, the test isn't any more complicated than if Calc were synchronous.
But we're lucky that Calc returns a channel. What if it doesn't?
Naive Approach
Let's say the Calc function looks like this:
var state atomic.Int32
// Calc calculates something asynchronously.
func Calc() {
go func() {
state.Store(42)
}()
}
We write a simple test and run it:
func TestNaive(t *testing.T) {
Calc()
got := state.Load() // (X)
if got != 42 {
t.Errorf("got: %v; want: 42", got)
}
}
=== RUN TestNaive
main_test.go:27: got: 0; want: 42
--- FAIL: TestNaive (0.00s)
The assertion fails because at point ⓧ, we didn't wait for the inner Calc goroutine to finish. In other words, we didn't synchronize the TestNaive and Calc goroutines. That's why state still has its initial value (0) when we do the check.
Waiting with time.Sleep
We can add a short delay with time.Sleep:
func TestSleep(t *testing.T) {
Calc()
// Wait for the goroutine to finish (if we're lucky).
time.Sleep(50 * time.Millisecond)
got := state.Load()
if got != 42 {
t.Errorf("got: %v; want: 42", got)
}
}
=== RUN TestSleep
--- PASS: TestSleep (0.05s)
The test is now passing. But using time.Sleep to sync goroutines isn't a great idea, even in tests. We don't want to set a custom delay for every function we're testing. Also, the function's execution time may be different on the local machine compared to a CI server. If we use a longer delay just to be safe, the tests will end up taking too long to run.
Sometimes you can't avoid using time.Sleep in tests, but since Go 1.25, the synctest package has made these cases much less common. Let's see how it works.
Waiting with synctest
The synctest package has a lot going on under the hood, but its public API is very simple:
func Test(t *testing.T, f func(*testing.T))
func Wait()
The synctest.Test function creates an isolated bubble where you can control time to some extent. Any new goroutines started inside this bubble become part of the bubble. So, if we wrap the test code with synctest.Test, everything will run inside the bubble — the test code, the Calc function we're testing, and its goroutine.
func TestSync(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
Calc()
// (X)
got := state.Load()
if got != 42 {
t.Errorf("got: %v; want: 42", got)
}
})
}
At point ⓧ, we want to wait for the Calc goroutine to finish. The synctest.Wait function comes to the rescue! It blocks the calling goroutine until all other goroutines in the bubble are finished. (It's actually a bit more complicated than that, but we'll talk about it later.)
In our case, there's only one other goroutine (the inner Calc goroutine), so Wait will pause until it finishes, and then the test will move on.
func TestSync(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
Calc()
// Wait for the goroutine to finish.
synctest.Wait()
got := state.Load()
if got != 42 {
t.Errorf("got: %v; want: 42", got)
}
})
}
=== RUN TestSync
--- PASS: TestSync (0.00s)
Now the test passes instantly. That's better!
Checking the Channel State
As we've seen, you can use synctest.Wait to wait for the tested goroutine to finish, and then check the state of the data you are interested in. You can also use it to check the state of channels.
Let's say there's a function that generates N numbers like 11, 22, 33, and so on:
// Generate produces n numbers like 11, 22, 33, ...
func Generate(n int) <-chan int {
out := make(chan int)
go func() {
for i := range n {
out <- (i+1)*10 + (i + 1)
}
}()
return out
}
And a simple test:
func Test(t *testing.T) {
out := Generate(2)
var got int
got = <-out
if got != 11 {
t.Errorf("#1: got %v, want 11", got)
}
got = <-out
if got != 22 {
t.Errorf("#1: got %v, want 22", got)
}
}
PASS
Set N=2, get the first number from the generator's output channel, then get the second number. The test passed, so the function works correctly. But does it really?
Let's use Generate in "production":
func main() {
for v := range Generate(3) {
fmt.Print(v, " ")
}
}
11 22 33 fatal error: all goroutines are asleep - deadlock!
Panic! We forgot to close the out channel when exiting the inner Generate goroutine, so the for-range loop waiting on that channel got stuck.
Let's fix the code:
// Generate produces n numbers like 11, 22, 33, ...
func Generate(n int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for i := range n {
out <- (i+1)*10 + (i + 1)
}
}()
return out
}
And add a test for the out channel state:
func Test(t *testing.T) {
out := Generate(2)
<-out // 11
<-out // 22
// (X)
// Check that the channel is closed.
select {
case _, ok := <-out:
if ok {
t.Errorf("expected channel to be closed")
}
default:
t.Errorf("expected channel to be closed")
}
}
--- FAIL: Test (0.00s)
main_test.go:41: expected channel to be closed
The test is still failing, even though we're now closing the channel when the Generate goroutine exits.
This is a familiar problem: at point ⓧ, we didn't wait for the inner Generate goroutine to finish. We need to use synctest.Wait:
func Test(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
out := Generate(2)
<-out // 11
<-out // 22
// Wait for the goroutine to finish.
synctest.Wait()
// Check that the channel is closed.
select {
case _, ok := <-out:
if ok {
t.Errorf("expected channel to be closed")
}
default:
t.Errorf("expected channel to be closed")
}
})
}
PASS
Now the test passes!
Checking for Leaks
Sometimes you want to make sure that a goroutine doesn't leak. For example, if a server starts a goroutine that should stop when the server stops, you want to verify that the goroutine actually stops.
Let's say we have a server that produces consecutive integers:
// IncServer produces consecutive integers starting from 0.
type IncServer struct {
// ...
}
// Start runs the server in a separate goroutine and
// sends numbers to the out channel.
func (s *IncServer) Start(out chan<- int)
// Stop stops the server.
func (s *IncServer) Stop()
We want to test that when we call Stop, the server goroutine actually stops. We can do this by checking that the goroutine count doesn't increase after Stop is called.
However, with synctest, we can do this more elegantly. If a goroutine doesn't stop when it should, it will remain in the bubble, and synctest.Test will panic when it finishes:
func Test(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
nums := make(chan int)
server := new(IncServer)
server.Start(nums)
// Get some numbers.
got := [3]int{<-nums, <-nums, <-nums}
want := [3]int{0, 1, 2}
if got != want {
t.Errorf("First 3: got: %v; want: %v", got, want)
}
// Stop the server.
server.Stop()
// Wait for the server goroutine to finish.
synctest.Wait()
})
}
If the server goroutine doesn't stop, synctest.Test will panic when it tries to finish, indicating a leak.
Durable Blocking
Not all blocking operations are visible to the synctest bubble. Only durable blocks are managed by the bubble.
A durable block is a blocking operation that the bubble can detect and control. The following operations are durable blocks:
- A blocking send or receive on a channel created within the bubble.
- A blocking select statement where every case is a channel created within the bubble.
- Calling
Cond.Wait. - Calling
WaitGroup.Waitif allWaitGroup.Addcalls were made inside the bubble. - Calling
time.Sleep.
Operations that are not durable blocks include:
- Blocking on channels created outside the bubble.
- Blocking on system calls.
- Blocking on I/O operations.
Instant Waiting
One of the key features of synctest is that it can make time pass instantly when all goroutines are durably blocked. This means that time.Sleep calls inside the bubble don't actually take real time — the bubble advances its fake clock to skip over the sleep duration.
For example:
func Test(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
time.Sleep(1 * time.Hour)
elapsed := time.Since(start)
fmt.Println("Elapsed:", elapsed)
})
}
Elapsed: 1h0m0s
The test completes instantly, even though we slept for an hour!
Time Inside the Bubble
The bubble uses a fake clock that starts at 2000-01-01 00:00:00 UTC. Time in the bubble only moves forward when all goroutines are durably blocked. The bubble advances time by the smallest amount needed to unblock at least one goroutine.
This means that:
time.Now()returns the current fake time, not real time.time.Sleepdoesn't actually wait — the bubble just advances the clock.- Timers and tickers work with the fake clock.
Checking for Cleanup
Sometimes you need to verify that cleanup code runs when a test finishes. For example, you might want to check that a server stops properly.
Using defer
You can use defer to ensure cleanup runs:
func Test(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
nums := make(chan int)
server := new(IncServer)
server.Start(nums)
defer server.Stop()
got := [3]int{<-nums, <-nums, <-nums}
want := [3]int{0, 1, 2}
if got != want {
t.Errorf("First 3: got: %v; want: %v", got, want)
}
})
}
However, defer runs when the function returns, which might be before all assertions complete.
Using t.Cleanup
A better approach is to use t.Cleanup, which runs when the test finishes:
func Test(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
nums := make(chan int)
server := new(IncServer)
server.Start(nums)
t.Cleanup(server.Stop)
got := [3]int{<-nums, <-nums, <-nums}
want := [3]int{0, 1, 2}
if got != want {
t.Errorf("First 3: got: %v; want: %v", got, want)
}
})
}
The t.Cleanup approach works because it calls Stop when synctest.Test has finished — after all the assertions have already run.
Using t.Context
Sometimes, a context (context.Context) is used to stop the server instead of a separate method. In that case, our server interface might look like this:
// IncServer produces consecutive integers starting from 0.
type IncServer struct {
// ...
}
// Start runs the server in a separate goroutine and
// sends numbers to the out channel until the context is canceled.
func (s *IncServer) Start(ctx context.Context, out chan<- int)
Now we don't even need to use defer or t.Cleanup to check whether the server stops when the context is canceled. Just pass t.Context() as the context:
func Test(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
nums := make(chan int)
server := new(IncServer)
server.Start(t.Context(), nums)
got := [3]int{<-nums, <-nums, <-nums}
want := [3]int{0, 1, 2}
if got != want {
t.Errorf("First 3: got: %v; want: %v", got, want)
}
})
}
PASS
t.Context() returns a context that is automatically created when the test starts and is automatically canceled when the test finishes.
Here's how it works:
- The main test code runs.
- Before the test finishes, the
t.Context()context is automatically canceled. - The server goroutine stops (as long as the server is implemented correctly and checks for context cancellation).
synctest.Testsees that there are no blocked goroutines and finishes without panicking.
Summary
To check for stopping via a method or function, use defer or t.Cleanup().
To check for cancellation or stopping via context, use t.Context().
Inside a bubble, t.Context() returns a context whose channel is associated with the bubble. The context is automatically canceled when synctest.Test ends.
Functions registered with t.Cleanup() inside the bubble run just before synctest.Test finishes.
Bubble Rules
Let's go over the rules for living in the synctest bubble.
General
- A bubble is created by calling
synctest.Test. Each call creates a separate bubble. - Goroutines started inside the bubble become part of it.
- The bubble can only manage durable blocks. Other types of blocks are invisible to it.
synctest.Test
- If all goroutines in the bubble are durably blocked with no way to unblock them (such as by advancing the clock or returning from a
synctest.Waitcall),Testpanics. - When
Testfinishes, it tries to wait for all child goroutines to complete. However, if even a single goroutine is durably blocked,Testpanics. - Calling
t.Context()returns a context whose channel is associated with the bubble. - Functions registered with
t.Cleanup()run inside the bubble, immediately beforeTestreturns.
synctest.Wait
- Calling
Waitin a bubble blocks the goroutine that called it. Waitreturns when all other goroutines in the bubble are durably blocked.Waitreturns when all other goroutines in the bubble have finished.
Time
- The bubble uses a fake clock (starting at 2000-01-01 00:00:00 UTC).
- Time in the bubble only moves forward if all goroutines are durably blocked.
- Time advances by the smallest amount needed to unblock at least one goroutine.
- If the bubble has to choose between moving time forward or returning from a running
synctest.Wait, it returns fromWait.
The following operations durably block a goroutine:
- A blocking send or receive on a channel created within the bubble.
- A blocking select statement where every case is a channel created within the bubble.
- Calling
Cond.Wait. - Calling
WaitGroup.Waitif allWaitGroup.Addcalls were made inside the bubble. - Calling
time.Sleep.
Limitations
The synctest limitations are quite logical, and you probably won't run into them.
Don't create channels or objects that contain channels (like tickers or timers) outside the bubble. Otherwise, the bubble won't be able to manage them, and the test will hang:
func Test(t *testing.T) {
ch := make(chan int)
synctest.Test(t, func(t *testing.T) {
go func() { <-ch }()
synctest.Wait()
close(ch)
})
}
panic: test timed out after 3s
Don't access synchronization primitives associated with a bubble from outside the bubble:
func Test(t *testing.T) {
var ch chan int
synctest.Test(t, func(t *testing.T) {
ch = make(chan int)
})
close(ch)
}
panic: close of synctest channel from outside bubble
Don't call T.Run, T.Parallel, or T.Deadline inside a bubble:
func Test(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
t.Run("subtest", func(t *testing.T) {
t.Log("ok")
})
})
}
panic: testing: t.Run called inside synctest bubble
Don't call synctest.Test inside the bubble:
func Test(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
t.Log("ok")
})
})
}
panic: synctest.Run called from within a synctest bubble
Don't call synctest.Wait from outside the bubble:
func Test(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
t.Log("ok")
})
synctest.Wait()
}
panic: goroutine is not in a bubble [recovered, repanicked]
Don't call synctest.Wait concurrently from multiple goroutines:
func Test(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
go synctest.Wait()
go synctest.Wait()
})
}
panic: wait already in progress
Summary
The synctest package is a complicated beast. But now that you've studied it, you can test concurrent programs no matter what synchronization tools they use—channels, selects, wait groups, timers or tickers, or even time.Sleep.
Key points to remember:
- Use
synctest.Testto create an isolated testing bubble for concurrent code. - Use
synctest.Waitto wait for goroutines to finish instead oftime.Sleep. - Durable blocks are managed by the bubble; other blocks are not.
- Time in the bubble is fake and advances instantly when all goroutines are blocked.
- Use
t.Cleanup()ort.Context()** to verify cleanup code runs properly. - Follow the bubble rules to avoid panics and test failures.
With synctest, you can write fast, reliable tests for concurrent Go code without relying on arbitrary delays or complex synchronization logic.






