1. Why concurrency tests love to hang
Concurrency tests are like checking a fire alarm: if it only goes off “when you get lucky”, it’s not an alarm, it’s a decorative plastic circle on the ceiling. Concurrent code often breaks not with a “compile error”, but because one goroutine waits for another, that one waits for a third, and in the end they all stare at each other—like a line at a clinic.
The main danger here is that a hung test looks like “it’s just taking a long time”. CI may spin it for 10 minutes, then 30, then in despair you click “Cancel job”… and nobody knows whether there was a crash, a race, a deadlock, or GitHub Actions was just in a bad mood.
Mini example: “how to create a hang with one line”
package conc
import (
"testing"
)
func TestHangForever(t *testing.T) {
ch := make(chan int)
_ = <-ch // nobody writes => the test will hang forever
}
This test won’t fail. It won’t “fail”. It will just never finish. And the worst part: sometimes a similar test can “accidentally” finish locally, but hang in CI (due to different goroutine scheduling).
2. The concurrency-test contract: “wait, but with a limit”
If we simplify it to one sentence, a good concurrent-code test is a test that can say: “I’ll wait for completion, but I won’t wait forever.” For that, we need a waiting timeout.
In Go, the classic approach is select, which listens for either “success” (a done signal) or “too late” (a timeout). This pattern is part of the basic culture of concurrent Go: the timeout is implemented by a channel that delivers a signal later, and select chooses what happens first.
Done channel: the simplest “I’m finished” signal
A “done” signal is typically expressed as chan struct{}. It’s a channel with no data: what matters is not “what”, but “that the fact happened”.
package conc
import (
"testing"
"time"
)
func TestDoneWithTimeout(t *testing.T) {
done := make(chan struct{})
go func() {
defer close(done)
time.Sleep(10 * time.Millisecond)
}()
select {
case <-done:
// ok: goroutine finished
case <-time.After(100 * time.Millisecond):
t.Fatalf("timeout: goroutine didn't finish")
}
}
Note the semantic discipline: the goroutine that does the work closes done itself. The test only waits. That’s a good “protocol”: whoever knows when everything is done is the one who signals it.
Why time.Sleep is bad “synchronization”
It’s very tempting to write: “Start a goroutine, wait 50ms, check the result.” It seems logical: “my function is fast.” But that’s a trap. In concurrency, “fast” is the word that reliably ruins reputations.
The problem with time.Sleep in tests is that it’s not waiting for an event—it’s betting on timing. If the machine is under load, the scheduler runs the goroutine later, GC steps in, CI is overheating—your test becomes flaky (sometimes passes, sometimes fails).
package conc
import (
"sync/atomic"
"testing"
"time"
)
func TestSleepIsNotSync(t *testing.T) {
var flag int32
go func() {
atomic.StoreInt32(&flag, 1)
}()
time.Sleep(1 * time.Millisecond) // betting on luck
if atomic.LoadInt32(&flag) != 1 {
t.Fatalf("flag not set yet")
}
}
Even if you increase sleep to 100ms, you won’t make the test correct—you’ll just make it slower, and still won’t guarantee stability.
The right idea: instead of “wait some time” we must “wait for an event”.
3. Waiting helpers: done channel and WaitGroup with a timeout
In real tests you don’t want to copy select { case <-done: ... case <-time.After(...): ... } ten times in a row. First, it’s noise. Second, noise in concurrency tests masks the real logic—and it’s already tense enough.
Let’s make a small helper. We already know t.Helper(), so the error message will point to the call site line, not the line inside the helper.
Helper: “wait, but not forever” for a done channel
package conc
import (
"testing"
"time"
)
func waitDone(t *testing.T, done <-chan struct{}, timeout time.Duration) {
t.Helper()
select {
case <-done:
return
case <-time.After(timeout):
t.Fatalf("timeout after %s", timeout)
}
}
And now the test reads like a human wrote it:
package conc
import (
"testing"
"time"
)
func TestSomething(t *testing.T) {
done := make(chan struct{})
go func() { close(done) }()
waitDone(t, done, 100*time.Millisecond)
}
It’s a small thing, but it strongly affects test quality: less noise means it’s easier to see what exactly we’re checking.
WaitGroup in tests: like “Wait”, but with a timeout
sync.WaitGroup is a great tool when you have multiple goroutines and want to wait for all of them. But wg.Wait() has a feature: it waits with no timeout. That is, if your goroutines hang, the test hangs with them.
So in tests people usually do this: wait for wg.Wait() in a separate goroutine, and the test waits on a done channel with a timeout. Yes, it’s a bit like nesting dolls, but it’s a normal price for a completion guarantee.
package conc
import (
"sync"
"testing"
"time"
)
func waitWG(t *testing.T, wg *sync.WaitGroup, timeout time.Duration) {
t.Helper()
done := make(chan struct{})
go func() {
defer close(done)
wg.Wait()
}()
select {
case <-done:
return
case <-time.After(timeout):
t.Fatalf("timeout waiting WaitGroup after %s", timeout)
}
}
The idea is simple: if your code is broken and wg.Done() wasn’t called somewhere, you’ll get a controlled test failure, not an endless CPU load and your sadness.
4. Context in tests: a “single kill switch” for stopping work
A timeout via time.After is good when the test “from the outside” just waits for an event. But in serious concurrent code it’s often important not only to “not wait forever”, but also to ask the code to stop: cancel work, stop workers, shut down processing.
That’s where context is king. context.Context has Done()—a channel that is closed on cancel/timeout—and Err(), explaining the reason. This is exactly the mechanism Go uses to “thread” cancellation across API boundaries and between goroutines.
Mini pattern: the test sets a timeout via context
package conc
import (
"context"
"testing"
"time"
)
func TestWithContextTimeout(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
select {
case <-ctx.Done():
// ok: the timeout fired
if ctx.Err() == nil {
t.Fatalf("expected ctx.Err() to be non-nil")
}
}
}
Here the test itself doesn’t check anything useful—it’s just an illustration of the mechanics. But the key idea matters: context is not “just another timeout method”, it’s a way to tell your concurrent code: “that’s it, we’re done.”
Table: time.After vs context.WithTimeout
| Tool | When it’s more convenient | What the wait looks like | What happens to the code under test |
|---|---|---|---|
|
when we wait for an event “from the outside” and don’t control the code | |
the test just stops waiting; the code may keep running on its own (if you didn’t stop it) |
|
when the code under test knows how to stop based on context | |
the test also signals cancellation, and the code should stop correctly |
If your code already accepts ctx, it’s usually better to keep the timeout in the context: you both limit waiting and request shutdown.
5. Mini example: a background task “saver”
So this doesn’t remain a set of abstract tricks, let’s assemble a small piece of logic that looks like a real application component: there is “task saving”, but we want to do it asynchronously (in the background), and the test must guarantee everything finishes and doesn’t hang.
Let’s say we have a Task and a Saver interface that can save a task somewhere (memory, file, network—doesn’t matter).
package taskapp
import "context"
type Task struct {
ID int
Title string
}
type Saver interface {
Save(ctx context.Context, t Task) error
}
Now, an async wrapper: it receives tasks via a channel and saves them in the background.
package taskapp
import (
"context"
)
type AsyncSaver struct {
s Saver
in chan Task
}
func NewAsyncSaver(s Saver) *AsyncSaver {
return &AsyncSaver{s: s, in: make(chan Task, 1)}
}
func (a *AsyncSaver) Enqueue(t Task) {
a.in <- t
}
func (a *AsyncSaver) Run(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case t := <-a.in:
_ = a.s.Save(ctx, t)
}
}
}
The code is intentionally simple: we’re not discussing architecture, 1000-task buffers, or graceful shutdown of the whole system. Right now we care about testing the concurrency mechanics.
Test: Run must stop when the context is canceled
Let’s make a fake saver that just counts calls.
package taskapp
import (
"context"
"sync/atomic"
)
type CountingSaver struct {
n int32
}
func (s *CountingSaver) Save(ctx context.Context, t Task) error {
atomic.AddInt32(&s.n, 1)
return nil
}
Now the test itself with a timeout and a done channel:
package taskapp
import (
"context"
"testing"
"time"
)
func TestAsyncSaverStopsOnCancel(t *testing.T) {
s := &CountingSaver{}
a := NewAsyncSaver(s)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
a.Run(ctx)
}()
a.Enqueue(Task{ID: 1, Title: "learn Go"})
cancel()
select {
case <-done:
// ok
case <-time.After(100 * time.Millisecond):
t.Fatalf("timeout: AsyncSaver.Run didn't stop")
}
}
Here we have two layers of protection: we ask it to stop (cancel()), and we don’t wait forever (a timeout in the test). This is the basic “skeleton” for any concurrency tests.
6. Result channels and buffering: how not to leak a goroutine in a test
In concurrency tests, this often happens: you start a goroutine, it should send a result into a channel, and the test waits for that result. If the test exits earlier due to a timeout, the sender can hang forever on ch <- result (if the channel is unbuffered and there is no receiver anymore).
This pitfall is so common that it’s mentioned in classic Go concurrency materials: it’s convenient to make timeout “signal” channels buffered so a goroutine won’t hang while sending a signal, even if the result is no longer needed by anyone.
Example: it’s safer to make a result channel buffered with size 1
package conc
import (
"testing"
"time"
)
func TestResultChannelBuffered(t *testing.T) {
result := make(chan int, 1)
go func() {
result <- 42
}()
select {
case v := <-result:
if v != 42 {
t.Fatalf("v=%d, want 42", v)
}
case <-time.After(100 * time.Millisecond):
t.Fatalf("timeout waiting result")
}
}
Why is “1” a magic number? Because we often only need the first result. A buffer of 1 guarantees the sender can finish regardless of whether the test has started reading yet.
If you need multiple values, you’ll have to think about the completion protocol more carefully (for example, who closes the channel, who reads and how many), but the idea is the same: don’t leave a goroutine in the state “I’m trying to send, but nobody is listening”.
7. Common mistakes
Mistake No. 1: “there’s a timeout, so the test won’t hang”—but the timeout is only in the test, and the goroutine keeps living.
Sometimes a test uses time.After, fails on timeout and finishes execution, but the started goroutine keeps running and holding resources: it blocks on a channel, spins in a loop, writes to a shared map. In large packages this leads to weird side effects: the next test fails even though it “shouldn’t”. The cure is shutdown discipline: if possible, the code under test should accept context and respect ctx.Done(), and the test should cancel that context.
Mistake No. 2: waiting via time.Sleep instead of waiting for an event.
time.Sleep in concurrency tests is betting on goroutine scheduling, and nobody promised you any schedule. It worked yesterday, doesn’t work today, and tomorrow it will “work on my machine”. The correct approach is to wait for an event via a channel (done, result) or via WaitGroup, and always bound the wait with a timeout.
Mistake No. 3: calling wg.Wait() directly in a test without a timeout.
WaitGroup isn’t at fault—we are. If wg.Done() didn’t happen due to a bug, Wait() will wait until the heat death of the universe (or until you kill the process). In tests, wrap wg.Wait() in a separate goroutine and wait on done with a timeout.
Mistake No. 4: unbuffered result channel + timeout in the test = a stuck goroutine.
The test timed out and exited, but the worker is trying to send the result—and hangs. Sometimes this is invisible because the test process still exits, but sometimes it triggers a cascade of problems in subsequent tests. A buffer of 1 often helps (if you need one result), or an explicit “close/cancel” protocol via context. The idea of buffering timeout signals and results is directly related to classic Go select-timeout patterns.
Mistake No. 5: “I’ll catch everything with recover.”
This is from a different opera, but it happens: trying to hide hanging/race issues with a “global try/catch”. In Go, that’s not the style, and it doesn’t work the way you want. A hang is not a panic. You solve it with a shutdown protocol and a waiting timeout, not with magic.
GO TO FULL VERSION