Concurrent code is notoriously difficult to test. Let's see how the new version of Go helps us here.

Testing Concurrent Code Using the Experimental 'testing/synctest' Package


The testing/synctest package helps solve two common testing problems:

  • Flaky tests involving time and concurrency
  • Slow tests that wait for time to pass

How It Works

The package provides a controlled environment called a “bubble” where:

  • Time starts at 2000-01-01 00:00:00 UTC
  • Time only advances when needed
  • The runtime controls all timing
  • Tests run instantly instead of waiting for real time

Basic Usage

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

func TestTimeOperation(t *testing.T) {
    synctest.Run(func() {
        start := time.Now()
        time.Sleep(time.Hour) // This returns instantly!
        end := time.Now()
        
        // Will be exactly 1 hour
        duration := end.Sub(start)
    })
}

When Goroutines Are Idle

Time advances in a bubble when all goroutines are idle. A goroutine is idle when:

  • Waiting on time.Sleep
  • Sending/receiving on a channel created in the bubble
  • Using select with only bubble-created channels
  • Calling sync.Cond.Wait

A goroutine is NOT idle when:

  • Making system calls
  • Making CGO calls
  • Doing I/O operations
  • Using mutexes

Common Use Cases

Testing Timeouts

func TestTimeout(t *testing.T) {
    synctest.Run(func() {
        ctx, cancel := context.WithTimeout(context.Background(), time.Second)
        defer cancel()
        
        // This runs instantly but behaves as if a second passed
        <-ctx.Done()
    })
}

Testing Tickers

func TestTicker(t *testing.T) {
    synctest.Run(func() {
        ticker := time.NewTicker(time.Millisecond)
        defer ticker.Stop()
        
        count := 0
        for i := 0; i < 1000; i++ {
            <-ticker.C
            count++
        }
        // This completes instantly despite simulating 1 second of ticks
    })
}

Synchronization Points

func TestSync(t *testing.T) {
    synctest.Run(func() {
        ch := make(chan bool)
        
        go func() {
            time.Sleep(time.Second)
            ch <- true
        }()
        
        synctest.Wait() // Waits for all goroutines to be idle
        <-ch           // Guaranteed to have data
    })
}

Key Restrictions

  1. Channel Rules

    • Channels created in a bubble must stay in that bubble
    • Using bubble channels from outside causes panic
  2. Timer Rules

    • Time only advances when all goroutines are idle
    • Timers and tickers must stay in their bubble
  3. Deadlock Detection

    • The bubble panics if all goroutines are idle with no pending timers

Setup Requirements

This is still experimental, so you’ll need to enable it, even when Go1.24 is released.

# For Go 1.24 and later
export GOEXPERIMENT=synctest
go test ./...

Further Reading