Skip to content

Chapter 22: Goroutine Run Loops

Description

Test goroutines with event loops that process messages, merge streams, fan-out work, and handle graceful shutdown via context cancellation or channel close. Run-loops are select-based goroutines that receive from one or more channels, process values, and send results.

Code

type RunLoop struct {
    ch   chan string
    done chan struct{}
}

func (r *RunLoop) Start() {
    go func() {
        for msg := range r.ch {
            r.mu.Lock()
            r.handled = append(r.handled, msg)
            r.mu.Unlock()
        }
        close(r.done)
    }()
}

func (r *RunLoop) Submit(msg string) { r.ch <- msg }

func (r *RunLoop) Stop() {
    close(r.ch)
    <-r.done
}

type Processor struct {
    input  <-chan int
    output chan<- int
    done   chan struct{}
}

func (p *Processor) Run(ctx context.Context) {
    defer close(p.done)
    for {
        select {
        case <-ctx.Done():
            return
        case v, ok := <-p.input:
            if !ok { return }
            p.output <- v * 2
        }
    }
}

Test

func TestRunLoop_StartStop(t *testing.T) {
    r := NewRunLoop()
    r.Start()
    r.Submit("hello")
    r.Submit("world")
    r.Stop()
    assert.Equal(t, []string{"hello", "world"}, r.Handled())
}

func TestRunLoop_Empty(t *testing.T) {
    r := NewRunLoop()
    r.Start()
    r.Stop()
    assert.Empty(t, r.Handled())
}

func TestProcessor(t *testing.T) {
    t.Run("processes until cancelled", func(t *testing.T) {
        input := make(chan int)
        output := make(chan int)
        p := NewProcessor(input, output)
        ctx, cancel := context.WithCancel(context.Background())
        go p.Run(ctx)
        input <- 5
        assert.Equal(t, 10, <-output)
        cancel()
        p.Wait()
    })
}

func TestFanOut(t *testing.T) {
    input := make(chan int, 5)
    for i := 1; i <= 5; i++ { input <- i }
    close(input)
    outs := FanOut(input, 3)
    got := []int{}
    for _, ch := range outs {
        for v := range ch { got = append(got, v) }
    }
    assert.ElementsMatch(t, []int{10, 20, 30, 40, 50}, got)
}

func TestMerger(t *testing.T) {
    ch1, ch2 := make(chan int, 3), make(chan int, 3)
    output := make(chan int, 6)
    m := NewMerger(output, ch1, ch2)
    go m.Run()
    <-m.started
    ch1 <- 1; ch2 <- 100; ch1 <- 2; ch2 <- 200; ch1 <- 3; ch2 <- 300
    close(ch1); close(ch2)
    m.Wait()
    got := []int{}
    for v := range output { got = append(got, v) }
    assert.ElementsMatch(t, []int{1, 2, 3, 100, 200, 300}, got)
}

Testing Approach

Goroutine run loop tests:

  1. Graceful shutdownStop() closes the input channel, the for-range loop exits naturally, and <-r.done waits for cleanup. This avoids context.Background() in simple cases.
  2. <-m.started synchronizer — the Merger exposes a started channel to signal when internal goroutines have been created. Without it, m.Wait() could close output before workers start.
  3. Cancellation via contextProcessor.Run(ctx) checks ctx.Done() in the select. Tests cancel the context and verify workers exit via < -p.Wait().
  4. Buffered channels for tests — test inputs use buffered channels with small capacities so they don't block. The merger output has exactly enough capacity for all expected values.

Run() Returns Channel

A variant appears when Run() returns the channel instead of receiving one. The method creates the channel internally, the caller observes from the outside — common when wrapping hardware or external sources that push events.

Code:

type Sensor struct {
    transport func(buf []byte) (int, error)
    interval  time.Duration
    stop      chan struct{}
}

func (s *Sensor) Run(ctx context.Context) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        ticker := time.NewTicker(s.interval)
        defer ticker.Stop()
        for {
            select {
            case <-ctx.Done():
                return
            case <-s.stop:
                return
            case <-ticker.C:
                buf := make([]byte, 4)
                n, err := s.transport(buf)
                if err != nil || n < 2 {
                    continue
                }
                out <- int(buf[0])<<8 | int(buf[1])
            }
        }
    }()
    return out
}

func (s *Sensor) Stop() { close(s.stop) }

Test:

func TestSensor_EmitsReadings(t *testing.T) {
    transport := func(buf []byte) (int, error) {
        buf[0] = 0x01; buf[1] = 0x90
        return 2, nil
    }

    s := NewSensor(transport, time.Millisecond)
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    ch := s.Run(ctx)

    var got int
    select {
    case v, ok := <-ch:
        if !ok { t.Fatal("channel closed unexpectedly") }
        got = v
    case <-time.After(100 * time.Millisecond):
        t.Fatal("timed out waiting for reading")
    }

    assert.Equal(t, 400, got) // 0x0190

    s.Stop()
    select {
    case _, ok := <-ch:
        if ok { for range ch {} }
    case <-time.After(100 * time.Millisecond):
        t.Fatal("timed out waiting for channel to close")
    }
}

func TestSensor_ClosesOnCancel(t *testing.T) {
    s := NewSensor(func(b []byte) (int, error) { return 0, nil }, time.Hour)
    ctx, cancel := context.WithCancel(context.Background())
    ch := s.Run(ctx)
    cancel()
    select {
    case _, ok := <-ch:
        if ok { t.Fatal("expected channel to close") }
    case <-time.After(100 * time.Millisecond):
        t.Fatal("timed out waiting for channel to close")
    }
}

Key differences from injected-channel run loops:

  1. External observation — the test doesn't own the channel. It must select with a timeout to capture events, then select again to verify cleanup.
  2. Two-phase shutdown — first stop the producer (s.Stop() or cancel()), then verify the channel closes. A for range drain after Stop() absorbs events sent before the goroutine sees the stop signal.
  3. Timeout as safety net — every channel read in the test has a <-time.After fallback. Without it, a stuck goroutine hangs the test forever.
  4. Separate close verification — the second select after shutdown confirms the goroutine exited and the channel closed. This catches goroutine leaks that don't affect output but accumulate in CI.

View source code on GitHub