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:
- Graceful shutdown —
Stop()closes the input channel, the for-range loop exits naturally, and<-r.donewaits for cleanup. This avoidscontext.Background()in simple cases. <-m.startedsynchronizer — the Merger exposes astartedchannel to signal when internal goroutines have been created. Without it,m.Wait()could close output before workers start.- Cancellation via context —
Processor.Run(ctx)checksctx.Done()in the select. Tests cancel the context and verify workers exit via< -p.Wait(). - 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:
- External observation — the test doesn't own the channel. It must
selectwith a timeout to capture events, thenselectagain to verify cleanup. - Two-phase shutdown — first stop the producer (
s.Stop()orcancel()), then verify the channel closes. Afor rangedrain afterStop()absorbs events sent before the goroutine sees the stop signal. - Timeout as safety net — every channel read in the test has a
<-time.Afterfallback. Without it, a stuck goroutine hangs the test forever. - Separate close verification — the second
selectafter 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