Test Goroutines and Channels in Go Code Without Sleeps
Test Go goroutines and channels without time.Sleep: range until the channel closes, use a select guard timeout, sort unordered results and check drains.

Concurrent code is hard to test because the test has to know when something has finished, and the natural way to find out is to wait a while. time.Sleep(100 * time.Millisecond) is that way, and it is both too long and too short: too long on the machine where everything finished in a millisecond, too short on the one where the scheduler was busy. The result is a suite that is slow and flaky at once.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
The alternative is to wait on the thing itself. Go's concurrency primitives all have a way to say "done", and a testable design uses it. This article shows three patterns on Go 1.25.6 for code built on goroutines and channels, with a small fan-in function as the subject.
The function
package main
import "sync"
// Merge fans several channels into one and closes it when all inputs close.
func Merge[T any](ins ...<-chan T) <-chan T {
out := make(chan T)
var wg sync.WaitGroup
wg.Add(len(ins))
for _, in := range ins {
go func() {
defer wg.Done()
for v := range in {
out <- v
}
}()
}
go func() { wg.Wait(); close(out) }()
return out
}
func main() {}Merge starts one goroutine per input channel that copies values to a shared output, and one more that waits for all of them and closes the output. That closing goroutine is what makes the function testable. A consumer can range over the result and the loop ends by itself when every input is exhausted. If Merge forgot to close its output, no test could tell when it was finished without guessing.
Three tests
package main
import (
"slices"
"sort"
"testing"
"time"
)
func gen(vs ...int) <-chan int {
c := make(chan int)
go func() {
defer close(c)
for _, v := range vs {
c <- v
}
}()
return c
}
func TestMergeCollectsAll(t *testing.T) {
var got []int
for v := range Merge(gen(1, 2, 3), gen(10, 20), gen()) { // ranging ends because Merge closes out
got = append(got, v)
}
sort.Ints(got) // order between inputs is not defined
t.Logf("got %v", got)
if !slices.Equal(got, []int{1, 2, 3, 10, 20}) {
t.Fatal("wrong values")
}
}
func TestReceiveWithTimeout(t *testing.T) {
never := make(chan int)
select {
case v := <-never:
t.Fatalf("unexpected %d", v)
case <-time.After(50 * time.Millisecond):
t.Log("nothing arrived, as expected")
}
}
func TestNoGoroutineLeft(t *testing.T) {
done := make(chan struct{})
go func() {
defer close(done)
for range Merge(gen(1), gen(2)) {
}
}()
select {
case <-done:
t.Log("pipeline drained and closed")
case <-time.After(2 * time.Second):
t.Fatal("pipeline never closed")
}
}go test -count=1 -v . 2>&1 | grep -vE "^(=== RUN|=== PAUSE|=== CONT|PASS|ok)" | sed -E "s/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: # #" got [1 2 3 10 20]
--- PASS: TestMergeCollectsAll
nothing arrived, as expected
--- PASS: TestReceiveWithTimeout
pipeline drained and closed
--- PASS: TestNoGoroutineLeftWait for the close
TestMergeCollectsAll merges three producers, one of them empty, and collects with for v := range. The range ends when Merge closes the channel, so the test gets exactly the values that were sent, no sooner and no later. It sorts them before comparing, because Go does not define the order in which several goroutines send, so the received sequence differs from run to run. The sorted result is [1 2 3 10 20].
Two rules fall out of this. Prefer designs where the producer closes the channel, since a closed channel is a completion signal every consumer understands. And assert on sets, sorted slices or counts when the order between independent senders is not part of the contract. If it is part of the contract, as with a pipeline of stages, assert on the order, and the test needs no sorting.
A risk worth naming: this test has no guard. If Merge hung, the test would block until the global timeout. For a test you trust, that is acceptable. For code that is under development, wrap the wait the way the third test does.
Prove that nothing happens
Testing that a value does not arrive is the awkward case, because absence cannot be waited for. TestReceiveWithTimeout selects on a channel that nobody sends to and on time.After(50 * time.Millisecond). The timer wins, and the test logs nothing arrived, as expected.
What that proves is limited. It shows that nothing arrived within 50 ms, not that nothing ever will. A buggy implementation that sends after 60 ms passes. So keep these tests few, use them for conditions that must be immediate (a rejected message must not be forwarded), and pick a window long enough to cover the code's own latency and short enough not to slow the suite. Where the code takes a clock, a virtual one removes the compromise: in a testing/synctest bubble, synctest.Wait returns when every goroutine is blocked, which is a proof of quiescence instead of a guess; see the clock article.
Guard every wait
TestNoGoroutineLeft runs a whole pipeline in a goroutine, for range Merge(gen(1), gen(2)) {}, and closes done when it ends. The test waits for done with a two second guard in a select. Normally the guard never fires: the log says pipeline drained and closed. If Merge never closed its output, the goroutine would block, and two seconds later the test would fail with pipeline never closed.
This is the pattern for any blocking wait. Pair the signal you want with time.After(guard) and fail on the guard. The guard should be large, since it only measures how long a hang takes to report. A hang reported in two seconds with a message beats one reported after ten minutes with a thousand lines of goroutine dump, though the dump is a useful fallback and -timeout should still be set.
For sync.WaitGroup, the same applies: wg.Wait() in a goroutine that closes a channel, and a select on that channel and the guard. Go 1.25 added WaitGroup.Go for starting the goroutine and counting it in one call, which removes the common bug of calling Add after the goroutine starts.
Also check what you started
A test that returns while goroutines the code started are still running leaks them into the next test, where a late write may fail an innocent neighbour. Make sure the code under test has a way to stop what it starts, usually a context or a closed channel, and have the test wait for it before returning. The flaky tests guide lists the symptoms. The race detector, covered in the CI article, will catch unsynchronised access that these tests miss.
Designing for tests
Three design choices make concurrent code far easier to test.
- Return channels or accept them, instead of hiding them. A function that starts a goroutine and offers no way to observe completion can only be tested with sleeps.
- Close from the sender. The goroutine that knows no more values will come is the one that closes.
Mergedoes this with its waiter goroutine. - Take a context. It lets the test end the work, as in context cancellation tests.
Where a design cannot offer a completion signal, as with a third-party callback, wrap it: have the callback send on a buffered channel the test can receive from.
Limits
These patterns test one interleaving per run, and the scheduler picks which. They are not a proof that the code is free of races or deadlocks. Run the tests with -race and -count=100 to exercise more interleavings, and keep the assertions independent of order. The Go blog on pipelines and cancellation is the standard description of the patterns used here, and the sync package documentation covers WaitGroup.


