every Tech Blog

株式会社エブリーのTech Blogです。

Go 1.25でのtesting/synctestを用いた並行処理テスト

開発2部の内原です。

Go 1.25がリリースされ、並行処理のテストを簡単にするtesting/synctestパッケージが正式に利用可能になりました。1.24では GOEXPERIMENT=synctest フラグが必要でしたが、1.25では不要になりました。

今回は実際にtesting/synctestを使って、その使い方や利点、注意点について紹介します。

testing/synctestとは

testing/synctestは、Go 1.24で実験的に導入され、Go 1.25で正式リリースされた並行処理テスト用のパッケージです。

このパッケージを使うことで、時間に依存する処理や非同期処理のテストを確実かつ高速に実行できます。

従来の課題

実行時間に依存したテストでは以下のような課題がありました。

  • time.Sleepによる待機でテストが遅くなる
  • タイミングによってテストが不安定になる(Flaky Test)
  • 非同期処理の完了を適切に待機するのが困難

基本的な使い方

testing/synctestには主に2つの関数があります。

  • synctest.Test: 新しいバブル(後述)環境でテスト関数を実行
  • synctest.Wait: バブル内のすべてのgoroutineが停止するまで待機

基本的な並行処理のテスト

従来の方法では、テストの実行時間が長くなったり、タイミングによって不安定になる問題がありました。

従来の方法(非synctest版):

func TestConcurrentOperationTraditional(t *testing.T) {
    var counter int
    var mu sync.Mutex
    var wg sync.WaitGroup

    // 複数のgoroutineを起動
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            time.Sleep(100 * time.Millisecond) // 実際に100ms待機
            mu.Lock()
            counter++
            mu.Unlock()
        }()
    }

    wg.Wait() // 実際に1秒以上かかる

    if counter != 10 {
        t.Errorf("expected counter to be 10, got %d", counter)
    }
}

synctest版:

func TestConcurrentOperation(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        var counter int
        var mu sync.Mutex
        var wg sync.WaitGroup

        // 複数のgoroutineを起動
        for i := 0; i < 10; i++ {
            wg.Add(1)
            go func() {
                defer wg.Done()
                time.Sleep(100 * time.Millisecond) // 瞬時に完了
                mu.Lock()
                counter++
                mu.Unlock()
            }()
        }

        synctest.Wait() // すべてのgoroutineが確実に完了するまで待機
        wg.Wait()

        if counter != 10 {
            t.Errorf("expected counter to be 10, got %d", counter)
        }
    })
}

タイムアウト処理のテスト

タイムアウトのテストは従来の方法では実際に時間が経過するため、テスト時間が長くなる問題がありました。

従来の方法(非synctest版):

func TestTimeoutBehaviorTraditional(t *testing.T) {
    ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
    defer cancel()

    start := time.Now()
    done := make(chan bool)

    go func() {
        time.Sleep(2 * time.Second) // 実際に2秒待機
        done <- true
    }()

    select {
    case <-done:
        t.Fatal("should have timed out")
    case <-ctx.Done():
        // 期待通りタイムアウト(実際に1秒経過)
        elapsed := time.Since(start)
        if elapsed < 900*time.Millisecond {
            t.Fatal("timeout too fast")
        }
    }
}

synctest版:

func TestTimeoutBehavior(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
        defer cancel()

        done := make(chan bool)

        go func() {
            time.Sleep(2 * time.Second) // バブル内で瞬時に完了
            done <- true
        }()

        select {
        case <-done:
            t.Fatal("should have timed out")
        case <-ctx.Done():
            // 期待通りタイムアウト(瞬時に完了)
        }
        // synctest.Wait()により、上記の処理が瞬時に完了
    })
}

ワーカープールのテスト

従来の方法(非synctest版):

func TestWorkerPoolTraditional(t *testing.T) {
    const numWorkers = 3
    const numJobs = 10

    jobs := make(chan int, numJobs)
    results := make(chan int, numJobs)
    var wg sync.WaitGroup

    // ワーカー起動
    wg.Add(numWorkers)
    for w := 1; w <= numWorkers; w++ {
        go func(id int) {
            defer wg.Done()
            for job := range jobs {
                // 処理時間をシミュレート
                time.Sleep(100 * time.Millisecond) // 実際に100ms待機
                results <- job * 2 // 2倍するworkerという位置付け
            }
        }(w)
    }

    // ジョブ送信
    go func() {
        for j := 1; j <= numJobs; j++ {
            jobs <- j
        }
        close(jobs)
    }()

    // ワーカーの完了を待機
    go func() {
        wg.Wait()
        close(results) // ワーカー完了後に結果チャンネルを閉じる
    }()

    // 結果回収(実際に1秒以上かかる)
    var resultCount int
    done := make(chan bool)
    go func() {
        for result := range results {
            resultCount++
            _ = result // 結果を処理
        }
        done <- true
    }()

    // 完了待機(タイムアウト処理が必要)
    select {
    case <-done:
        if resultCount != numJobs {
            t.Errorf("expected %d results, got %d", numJobs, resultCount)
        }
    case <-time.After(5 * time.Second):
        t.Fatal("worker pool processing timeout")
    }
}

synctest版:

func TestWorkerPool(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        const numWorkers = 3
        const numJobs = 10

        jobs := make(chan int, numJobs)
        results := make(chan int, numJobs)
        var wg sync.WaitGroup

        // ワーカー起動
        wg.Add(numWorkers)
        for w := 1; w <= numWorkers; w++ {
            go func(id int) {
                defer wg.Done()
                for job := range jobs {
                    // 処理時間をシミュレート
                    time.Sleep(100 * time.Millisecond) // バブル内で瞬時に完了
                    results <- job * 2
                }
            }(w)
        }

        // ジョブ送信
        go func() {
            for j := 1; j <= numJobs; j++ {
                jobs <- j
            }
            close(jobs)
        }()

        // ワーカーの完了を待機
        go func() {
            wg.Wait()
            close(results) // ワーカー完了後に結果チャンネルを閉じる
        }()

        // 結果回収
        var resultCount int
        for result := range results {
            resultCount++
            _ = result // 結果を処理
        }

        // すべての処理が確実に完了するまで待機(瞬時に完了)
        synctest.Wait()

        if resultCount != numJobs {
            t.Errorf("expected %d results, got %d", numJobs, resultCount)
        }
    })
}

この例では、synctestを使うことで以下の点が改善します。

  • タイムアウト処理が不要
  • 実行時間が短縮(1秒以上→瞬時)
  • 複雑な同期処理が動作することを保証

synctestの仕組み

バブル環境

synctestは「バブル」と呼ばれる隔離された実行環境を作成します。この環境では以下の差分があります。

  • 時間が制御される(fake clock)
  • すべてのgoroutineがブロックされた時のみ時間が進む

durably blocked

synctestにおけるdurably blockedとは、goroutineがブロックされており、同じバブル内の別のgoroutineかfake clockの更新によってのみブロック解除できる状態を指します。

synctest.Wait()は、バブル内のすべてのgoroutineがdurably blockedになるまで待機します。

利点

1. 高速なテスト実行

時間に依存する処理が瞬時に完了するため、テスト全体の実行時間が短縮されます。

2. Flaky Testの回避

synctest.Wait()により、すべての非同期処理の完了を待機できるため、タイミング依存のテスト失敗を防げます。

3. 決定的な実行

バブル内では実行が決定的になるため、再現可能なテストが書けます。

注意点

1. CGOとシステムコールの制限

システムコールやCGO呼び出しはdurably blockingではありません。synctestはGoコードを実行するgoroutineの状態のみを把握可能であるため、これらの操作中はfake clockが進まない場合があります。

2. 外部システムとの通信

バブル外のシステム(データベース、外部API等)との通信はsynctestの制御下にありません。これらを含むテストでは代わりにmockやstubの使用が必要になります。

3. 学習コスト

durably blockedなどの概念を理解する必要があり、従来のテストとは異なる考慮が必要です。

まとめ

Go 1.25のtesting/synctestにより、並行処理のテストを書きやすくなりましたが、一方でいくつかの制限事項もあるため、適用範囲を見極めて使用することが重要です。