Testing Concurrent Code with testing/synctest

Test concurrent Go code with virtualized time using the testing/synctest package

Testing concurrent code that involves timing dependencies can be challenging and flaky. The testing/synctest package provides a way to test concurrent code with virtualized time, making tests deterministic and fast.

Basic Synctest Usage

The testing/synctest package allows you to test time-dependent concurrent code by controlling the flow of time in your tests.

package main

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

// Function that waits for a timeout before returning a result.
func ProcessWithTimeout(ctx context.Context, data string) (string, error) {
	select {
	case <-time.After(5 * time.Second):
		return "processed: " + data, nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

func TestProcessWithTimeout(t *testing.T) {
	synctest.Run(t, func() {
		ctx := context.Background()
		
		start := time.Now()
		result, err := ProcessWithTimeout(ctx, "test-data")
		elapsed := time.Since(start)
		
		if err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
		
		if result != "processed: test-data" {
			t.Errorf("expected &#39;processed: test-data&#39;, got %q", result)
		}
		
		// In synctest, time advances instantly
		if elapsed > time.Millisecond {
			t.Errorf("test took too long: %v", elapsed)
		}
	})
}

Testing Concurrent Operations

Test concurrent operations that depend on timing without the unpredictability of real time:

package main

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

// Worker that processes items at intervals.
type TimedWorker struct {
	interval time.Duration
	results  []string
	mu       sync.Mutex
}

func (w *TimedWorker) Start(ctx context.Context, items []string) {
	ticker := time.NewTicker(w.interval)
	defer ticker.Stop()
	
	i := 0
	for {
		select {
		case <-ticker.C:
			if i >= len(items) {
				return
			}
			w.mu.Lock()
			w.results = append(w.results, "processed: "+items[i])
			w.mu.Unlock()
			i++
		case <-ctx.Done():
			return
		}
	}
}

func (w *TimedWorker) GetResults() []string {
	w.mu.Lock()
	defer w.mu.Unlock()
	return append([]string(nil), w.results...)
}

func TestTimedWorker(t *testing.T) {
	synctest.Run(t, func() {
		worker := &TimedWorker{interval: time.Second}
		items := []string{"item1", "item2", "item3"}
		
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		
		go worker.Start(ctx, items)
		
		// Wait for processing to complete
		time.Sleep(4 * time.Second)
		
		results := worker.GetResults()
		expected := []string{
			"processed: item1",
			"processed: item2", 
			"processed: item3",
		}
		
		if len(results) != len(expected) {
			t.Fatalf("expected %d results, got %d", len(expected), len(results))
		}
		
		for i, result := range results {
			if result != expected[i] {
				t.Errorf("result %d: expected %q, got %q", i, expected[i], result)
			}
		}
	})
}

Testing Race Conditions

Use synctest to expose race conditions that might be timing-dependent:

package main

import (
	"sync"
	"testing"
	"testing/synctest"
	"time"
)

// Counter with a potential race condition.
type Counter struct {
	value int
	mu    sync.Mutex
}

func (c *Counter) Increment() {
	// Simulated work that might expose race conditions.
	time.Sleep(time.Nanosecond)
	c.mu.Lock()
	c.value++
	c.mu.Unlock()
}

func (c *Counter) Value() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.value
}

func TestCounterConcurrency(t *testing.T) {
	synctest.Run(t, func() {
		counter := &Counter{}
		
		var wg sync.WaitGroup
		goroutines := 100
		wg.Add(goroutines)
		
		// Start multiple goroutines incrementing the counter.
		for i := 0; i < goroutines; i++ {
			go func() {
				defer wg.Done()
				counter.Increment()
			}()
		}
		
		wg.Wait()
		
		if counter.Value() != goroutines {
			t.Errorf("expected counter value %d, got %d", goroutines, counter.Value())
		}
	})
}

Best Practices

  • Use synctest.Run to wrap your concurrent test logic for deterministic time behavior.
  • Test timing-dependent code without relying on real wall-clock time.
  • Combine with race detection (go test -race) for comprehensive concurrent testing.
  • Focus on testing the logical correctness of concurrent algorithms rather than performance.

Common Pitfalls

  • Not wrapping test logic with synctest.Run, which prevents time virtualization.
  • Mixing real time operations with virtualized time in the same test.
  • Expecting real performance characteristics in synctest environment.

Performance Tips

  • Synctest makes tests faster by eliminating real time waits.
  • Use synctest for testing correctness, not performance characteristics.
  • Combine with benchmarks for performance testing of concurrent code.