Lesson 24 / 25

Testing Concurrent Code

Deterministic tests instead of sleeps and luck.

Control time and scheduling

Flaky concurrent tests usually rely on time.Sleep to "wait long enough". Better approaches: make concurrency injectable (pass channels, clocks or a worker count so a test can use 1 worker or a fake clock); wait on explicit signals (channels, WaitGroups) instead of sleeping; give every blocking wait in a test a timeout so a bug fails the test rather than hanging it; run tests with -race and with -count greater than 1 to shake out ordering bugs. Go 1.25 added the testing/synctest package (experimental in Go 1.24 behind GOEXPERIMENT=synctest): synctest.Test(t, func(t *testing.T) {...}) runs the function in an isolated bubble with a fake clock that advances only when every goroutine in the bubble is blocked, and synctest.Wait() waits until they all are. Timers and timeouts then complete instantly and deterministically. The API changed between versions, so check the docs for yours.

Testing a timeout with synctest (Go 1.25+)

The 5-second timeout completes without the test taking 5 real seconds.

package fetch

import (
	"context"
	"errors"
	"testing"
	"testing/synctest"
	"time"
)

func waitForResult(ctx context.Context, ch <-chan string) (string, error) {
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	select {
	case v := <-ch:
		return v, nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

func TestWaitForResultTimesOut(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		never := make(chan string) // nothing is ever sent
		start := time.Now()        // fake clock inside the bubble
		_, err := waitForResult(context.Background(), never)
		if !errors.Is(err, context.DeadlineExceeded) {
			t.Fatalf("want DeadlineExceeded, got %v", err)
		}
		if got := time.Since(start); got != 5*time.Second {
			t.Fatalf("fake time elapsed = %v", got)
		}
	})
}

// Shell: go test -race -count=20 ./...

Test the stop path too

For every component that starts goroutines, write a test that cancels it and then checks, with goleak or a WaitGroup, that every goroutine has exited. Shutdown bugs hide on paths nobody tests.

Quick check: Inside a testing/synctest bubble, when does the fake clock advance?

  • Only when the test calls time.Sleep in the main goroutine
  • Every real millisecond, like the normal clock
  • When every goroutine in the bubble is durably blocked
  • Never; timers inside a bubble are disabled
Answer

When every goroutine in the bubble is durably blocked — Time jumps forward to the next timer once nothing in the bubble can make progress, making timeouts deterministic.