Testing Concurrent Code
Testing Concurrent Code
Overview
Well-designed concurrent code is testable with normal tools: channels, wait groups, and fakes. Flaky tests usually mean missing synchronization or time.Sleep as assertion.
Diagram: Assert after barrier
flow:
[Barrier]
|
v
[Assert]
Principles
- Make state observable after a barrier (Wait, channel close, context cancel).
- Inject clocks and dependencies (interfaces) so tests control timing.
- Run with
-racealways in CI. - Prefer determinism over “sleep and hope”.
go test -race -count=100 ./...Pattern: wait for completion
func TestWorker(t *testing.T) {
done := make(chan struct{})
go func() {
defer close(done)
work()
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("timeout")
}
}Pattern: collect results
out := make(chan int, n)
// workers send to out
// close when done
var sum int
for v := range out {
sum += v
}Pattern: assert no leak
before := runtime.NumGoroutine()
// run cancellable system
// cancel and wait
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if runtime.NumGoroutine() <= before+margin {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("goroutine leak")Avoid sleeps as correctness
// brittle
go doWork()
time.Sleep(10 * time.Millisecond)
assertState()Prefer:
- channel signal when state is ready
- polling with timeout only when API is legacy
testing/synctestbubbles when available (full runtime model: synctest deep dive)
Testing cancel paths
ctx, cancel := context.WithCancel(context.Background())
errc := make(chan error, 1)
go func() { errc <- server.Run(ctx) }()
cancel()
select {
case err := <-errc:
if !errors.Is(err, context.Canceled) { t.Fatal(err) }
case <-time.After(time.Second):
t.Fatal("hang")
}Table tests still work
Concurrency does not forbid table tests—ensure each case uses fresh channels/groups and does not share mutable fixtures across parallel t.Run without isolation.
t.Parallel() // only if no shared mutable stateRunnable example
mkdir /tmp/conctest && cd /tmp/conctest
go mod init exampleinc_test.go:
package example
import (
"sync"
"sync/atomic"
"testing"
"time"
)
func TestAtomicInc(t *testing.T) {
var n atomic.Int64
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Go(func() { n.Add(1) })
}
wg.Wait()
if n.Load() != 100 {
t.Fatalf("got %d", n.Load())
}
}
func TestCancelPath(t *testing.T) {
done := make(chan struct{})
go func() {
defer close(done)
time.Sleep(5 * time.Millisecond)
}()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("timeout")
}
}go test -race -count=50 -vWhat to notice: Barrier (Wait / channel close) then assert—no Sleep as the correctness signal.
Try next: Write a test that fails under -race when two Gs append to the same slice without sync.