Skill

go-concurrency-testing

Patterns for testing concurrent Go code - race detection, goroutine leaks, context cancellation

From golang-workflow
Install
1
Run in your terminal
$
npx claudepluginhub jamesprial/prial-plugins --plugin golang-workflow
Tool Access

This skill uses the workspace's default tool permissions.

Skill Content

Concurrency Testing

Testing concurrent code requires specific patterns to catch races, leaks, and cancellation bugs.

Race Detection

Always run concurrent tests with -race:

go test -race ./...

Design tests that exercise concurrent paths:

func Test_Cache_ConcurrentAccess(t *testing.T) {
    cache := NewCache()

    var wg sync.WaitGroup
    for i := 0; i < 100; i++ {
        wg.Add(2)
        go func(key string) {
            defer wg.Done()
            cache.Set(key, "value")
        }(fmt.Sprintf("key-%d", i))
        go func(key string) {
            defer wg.Done()
            _ = cache.Get(key)
        }(fmt.Sprintf("key-%d", i))
    }
    wg.Wait()
}

Goroutine Leak Detection

Check that functions don't leak goroutines:

func Test_Worker_NoGoroutineLeak(t *testing.T) {
    before := runtime.NumGoroutine()

    w := NewWorker()
    w.Start()
    w.Process(task)
    w.Stop()

    // Allow goroutines to wind down
    time.Sleep(100 * time.Millisecond)

    after := runtime.NumGoroutine()
    if after > before+1 { // +1 tolerance for runtime goroutines
        t.Errorf("goroutine leak: before=%d after=%d", before, after)
    }
}

For more robust detection, use a polling approach:

func assertNoGoroutineLeak(t *testing.T, before int) {
    t.Helper()
    deadline := time.Now().Add(2 * time.Second)
    for time.Now().Before(deadline) {
        after := runtime.NumGoroutine()
        if after <= before+1 {
            return
        }
        time.Sleep(50 * time.Millisecond)
    }
    t.Errorf("goroutine leak: started with %d, now %d", before, runtime.NumGoroutine())
}

Context Cancellation

Test that functions respect context cancellation:

func Test_Fetch_RespectsContextCancel(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    cancel() // cancel immediately

    _, err := Fetch(ctx, "key")
    if !errors.Is(err, context.Canceled) {
        t.Errorf("expected context.Canceled, got %v", err)
    }
}

func Test_Fetch_RespectsContextDeadline(t *testing.T) {
    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
    defer cancel()

    time.Sleep(10 * time.Millisecond) // ensure deadline passes

    _, err := Fetch(ctx, "key")
    if !errors.Is(err, context.DeadlineExceeded) {
        t.Errorf("expected DeadlineExceeded, got %v", err)
    }
}

Channel Tests

Test channel-based communication patterns:

func Test_Producer_SendsAllItems(t *testing.T) {
    items := []string{"a", "b", "c"}
    ch := Producer(items)

    var received []string
    for item := range ch {
        received = append(received, item)
    }

    if len(received) != len(items) {
        t.Errorf("received %d items, want %d", len(received), len(items))
    }
}

func Test_Pipeline_ClosesOnContextCancel(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    ch := Pipeline(ctx, infiniteSource())

    // Read a few items
    <-ch
    <-ch

    cancel()

    // Channel should close after context cancellation
    select {
    case _, ok := <-ch:
        if ok {
            // May receive buffered items, that's fine
        }
    case <-time.After(time.Second):
        t.Error("channel not closed after context cancellation")
    }
}

Concurrent Stress Tests

For functions that must be safe under concurrent access:

func Test_Counter_ConcurrentIncrement(t *testing.T) {
    counter := NewCounter()
    n := 1000

    var wg sync.WaitGroup
    wg.Add(n)
    for i := 0; i < n; i++ {
        go func() {
            defer wg.Done()
            counter.Increment()
        }()
    }
    wg.Wait()

    if got := counter.Value(); got != n {
        t.Errorf("counter = %d, want %d (likely race condition)", got, n)
    }
}

Common Mistakes

// WRONG: Non-deterministic timing dependency
func Test_Async_Bad(t *testing.T) {
    go doWork()
    time.Sleep(100 * time.Millisecond) // fragile!
    checkResult(t)
}

// CORRECT: Use synchronization primitives
func Test_Async_Good(t *testing.T) {
    done := make(chan struct{})
    go func() {
        doWork()
        close(done)
    }()
    select {
    case <-done:
        checkResult(t)
    case <-time.After(5 * time.Second):
        t.Fatal("timed out waiting for work")
    }
}
Stats
Stars1
Forks0
Last CommitFeb 9, 2026