Testing Concurrent Code

Updated

September 8, 2026

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

  1. Make state observable after a barrier (Wait, channel close, context cancel).
  2. Inject clocks and dependencies (interfaces) so tests control timing.
  3. Run with -race always in CI.
  4. 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/synctest bubbles 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 state

Runnable example

mkdir /tmp/conctest && cd /tmp/conctest
go mod init example

inc_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 -v

What 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.