Go 1.25 testing/synctestの使い所とは? もう非同期処理を含むテストで悩まない
こんにちは @glassmonekey です。
先日のGo Conference 2025ではencoding/json/v2についてトークをしました。
旬な機能繋がりではないですが、今回はGo 1.25で正式に導入されたtesting/synctestについて紹介します。
並行処理のテストは、タイミング依存で不安定になったり、time.Sleep()で適当に待ったりしがちです。testing/synctestはそんな悩みを解決してくれますが、すべてのケースで使えるわけではありません。便利そうな反面、使い所がわからず迷っていたので、この記事では向き不向きを整理してみました。
TL;DR
testing/synctestは並行処理を決定論的にテストできるパッケージです。
できること:
- 時間制御(30分のタイムアウトのテスト一瞬で終わる)
- 決定論的なテスト実行(タイミング依存を排除)
- チャネル、mutex、WaitGroup等のGo標準並行処理をつかった決定論的なテスト
- time.Sleep、time.After等の時間操作
できないこと:
- HTTP通信(httptest.NewServer含む)
- ファイルI/O、DB接続
- その他の実際のI/O操作
Go標準の並行処理には最適ですが、I/O操作では使えなかったりします。つまり、使い所の見極めが重要になってきます。
testing/synctestとは
testing/synctestは、並行処理のテストを決定論的に実行できるパッケージです。
公式ドキュメントでは次のように説明されています:
Package synctest provides support for testing concurrent code.
The Test function runs a function in an isolated "bubble". Any goroutines started within the bubble are also part of the bubble.
つまり、testing/synctestはbubbleと呼ばれる隔離された実行環境でテストコードを実行し、その中で起動されたすべてのgoroutineを管理します。bubbleは外部から独立しており、内部で作成されたチャネルやタイマーなども含めてbubbleに紐づきます(詳細は後述)。
従来の並行処理テストの課題:
-
goroutineの実行タイミングに依存して不安定 - 長時間の待機(
time.Sleep(10 * time.Minute)等)でテストが遅くなる - 複数の
goroutineの完了待機が複雑
testing/synctestでできること:
-
時間を制御:
bubble内では偽のクロックを使用し、すべてのgoroutineがブロックされたときだけ時間が進む(例:1時間の待機が一瞬で終わる) -
確実な完了待機:
synctest.Wait()を使うことで、bubble内のすべてのgoroutineがdurably blocked(同じbubble内の別のgoroutineによってのみブロック解除できる状態)になるまで待機できる - 決定論的な実行:タイミング依存の不安定さを排除し、常に同じ結果を得られる
主なAPI:
-
synctest.Test(t, func(t *testing.T)):bubble環境を作成してテストを実行 -
synctest.Wait():bubble内の他のすべてのgoroutineがdurably blockedになるまで待機
補足: APIについて
Go 1.25で正式採用されるにあたり、APIが変更されました(#73567)。
主な変更:
-
synctest.Run(func())→synctest.Test(t, func(t *testing.T))
この変更により、T.CleanupやT.Contextがbubble内で正しく動作するようになりました。実験的機能を使っていた方は、Testへの移行が必要です。
補足:synctestはGoのランタイムにも大きく手を入れて実装されています。時間制御の仕組みに興味がある方は、runtime/time.goを読んでみることをおすすめします。
まずは使ってみる
タイムアウト処理のテストを、従来の方法とsynctestで比較してみます。
従来の方法(testing/synctestなし)
func ProcessWithTimeout(input chan string) (string, error) {
select {
case result := <-input:
return result, nil
case <-time.After(30 * time.Minute):
return "", fmt.Errorf("timeout")
}
}
func TestProcessWithTimeout_従来(t *testing.T) {
ch := make(chan string)
// タイムアウトのテスト
start := time.Now()
_, err := ProcessWithTimeout(ch)
elapsed := time.Since(start)
if err == nil {
t.Error("expected timeout error")
}
t.Logf("経過時間: %v", elapsed)
}
課題:
- テストに実際に30分かかる
- タイミング依存で不安定
testing/synctestを使った方法
testing/synctestを使うには、以下の2つを押さえておけばOKです:
-
synctest.Test()でテストコードを囲む:この中がbubble環境になる -
必要に応じて
synctest.Wait()を呼ぶ:すべてのgoroutineがブロックされるまで待つ
func ProcessWithTimeout(input chan string) (string, error) {
select {
case result := <-input:
return result, nil
case <-time.After(30 * time.Minute):
return "", fmt.Errorf("timeout")
}
}
func TestProcessWithTimeout_synctest(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ch := make(chan string)
start := time.Now()
_, err := ProcessWithTimeout(ch)
elapsed := time.Since(start)
if err == nil {
t.Error("expected timeout error")
}
t.Logf("経過時間: %v", elapsed)
})
}
改善:
- テストが一瞬で終わる
- 決定論的で安定
30分のタイムアウトが一瞬で終わります。仕組みを図で見てみましょう:
全goroutineがブロック状態になると、testing/synctestが自動的に時間を進めます。
testing/synctestの動作原理
testing/synctestがどのように動作するのか、公式ドキュメントの説明を元に詳しく見ていきましょう。
bubbleとは
まず、synctestを理解する上で最も重要な概念であるbubbleについて説明します。
公式ドキュメントでは以下のように説明されています:
The Test function runs a function in an isolated "bubble". Any goroutines started within the bubble are also part of the bubble.
Within a bubble, the time package uses a fake clock. Each bubble has its own clock.
bubbleは隔離された実行環境で、以下の特徴があります:
bubble内:
-
synctest.Test()内で起動されたgoroutineがすべて含まれる - 時間が独自の偽の時計(fake clock)で制御される
- 初期時刻は常に
2000-01-01 00:00:00 UTCに固定される - チャネル、タイマーなどが
bubbleに関連付けられる -
goroutineの状態をsynctestが監視できる
bubble外:
- 通常のGo実行環境
- 実際の時間(real time)が流れる
-
bubble内のチャネルやタイマーへの操作はpanicする -
synctestによる制御を受けない
具体例で見てみましょう。以下のコードでは、bubble内で10秒待機し、bubble外で1秒待機しています。一見するとoutside bubble → inside bubbleの順で表示されそうですが、bubble内の時間は瞬時に進むため、必ずinside bubble → outside bubbleの順で表示されます:
start := time.Now()
synctest.Test(t, func(t *testing.T) {
bubbleStart := time.Now() // bubble内の時刻:2000-01-01 00:00:00
time.Sleep(10 * time.Second)
fmt.Println("inside bubble", time.Since(bubbleStart)) // 10s
})
time.Sleep(1 * time.Second)
fmt.Println("outside bubble", time.Since(start))
このような隔離環境により、synctestは時間を制御し、決定論的なテストを実現します。
時間の制御
公式ドキュメントでは、時間の進み方について以下のように説明されています:
Time in a bubble only advances when every goroutine in the bubble is durably blocked.
bubble内の時間は、bubble内のすべてのgoroutineがdurably blocked(永続的ブロック)状態になったときだけ進みます。これがsynctestの時間制御の核心です。
具体的には:
- すべての
goroutineがブロックされるのを待つ - ブロックされた
goroutineの中で、最も早く解除されるものを見つける - その時刻まで一気に時間を進める
- 該当する
goroutineを再開する
これにより、実際には数時間かかる処理も一瞬でテストできます。
Wait関数の役割
synctest.Wait()は、bubble内の他のすべてのgoroutineがdurably blockedになるまで待機します:
The Wait function blocks until all other goroutines in the bubble are durably blocked.
synctest.Test(t, func(t *testing.T) {
done := false
go func() {
time.Sleep(1 * time.Hour)
done = true
}()
synctest.Wait() // 他のすべてのgoroutineがブロックされるまで待つ
// ここに到達する時点で、goroutineは完了している
if !done {
t.Error("goroutine not completed")
}
})
Durably Blocked vs Non-durably Blocked
公式ドキュメントでは、durably blockedを以下のように定義しています:
A goroutine in a bubble is "durably blocked" when it is blocked and can only be unblocked by another goroutine in the same bubble.
synctestは、goroutineのブロック状態を2つに分類します(runtime/synctest.goで定義):
Durably Blocked(永続的ブロック) - synctestで扱える
bubble内の他のgoroutineによってのみブロックが解除される状態:
- チャネルの送受信:
ch <- data、<-ch - mutexのロック待ち:
mu.Lock() time.Sleep()- タイマー:
time.After()、time.Tick() synctest.Wait()
これらはGo標準の並行処理で、synctestで時間制御できます。
Non-durably Blocked(一時的ブロック) - synctestで扱えない
bubble外の要因(ネットワークI/Oなど)でブロックが解除される可能性がある状態です。
代表的な例:
- ネットワークI/O(例:
http.Get()、net.Conn.Read()) - ファイルI/O(例:
os.ReadFile()) - データベース操作(例:
db.Query()) - その他のシステムコール全般
これらのような実際のI/O操作を伴う処理は、synctestでは制御できません。
重要:non-durably blockedな処理があると、時間が進まずテストが完了しません。これがsynctestの最大の制約です。
なぜI/Oはdurably blockedではないのか
公式ドキュメントでは、durably blockedを以下のように定義しています:
A goroutine is durably blocked if it can only be unblocked by another goroutine in its bubble.
つまり、durably blockedは「bubble内の他のgoroutineによってのみブロックが解除される状態」と定義されています。I/O操作については明確に除外されています:
A goroutine executing a system call or waiting for an external event such as a network operation is not durably blocked. For example, a goroutine blocked reading from a network connection is not durably blocked even if no data is currently available on the connection, because it may be unblocked by data written from outside the bubble or may be in the process of receiving data from a kernel network buffer.
ネットワーク接続からの読み取りを待っているgoroutineは、たとえ現時点でデータがなくても、bubble外からのデータ書き込みや、カーネルネットワークバッファからのデータ受信によってブロックが解除される可能性があります。
例えば、ループバックTCP接続で一方から書き込み、もう一方で読み取る場合、読み取り側のgoroutineはカーネルが接続を読み取り可能と判断するまでの短時間、I/O待ち状態になります。この状態でもカーネルバッファにはデータが到着している可能性があり、bubble外の要因でブロックが解除されます。
synctestは決定論的なテストを実現するため、durably blockedを「bubble内で完結するブロック」として定義し、外部要因に依存するI/O操作は含めていません。
注意点
ネスト禁止
ネストがあるとパニックを起こします。
// ❌ これはpanic
func TestNested(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// panic: synctest: Test is not supported within a bubble
})
})
}
実行順序の非決定性
synctestは時間を制御しますが、goroutineの実行順序まで完全に制御するわけではありません。状態に依存しない設計が重要です。
// ⚠️ このテストは不安定
func TestRaceCondition(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var value int
go func() { value = 1 }()
go func() { value = 2 }()
synctest.Wait()
// valueが1か2かは不定
// 適切な同期が必要
})
}
グローバル状態への影響
time.Now()などは、bubble内でのみ制御されます。bubble外のコードには影響しません。
testing/synctestの向き不向き
testing/synctestは、goroutineをはじめとした並行処理を決定論的にテストできる非常に強力なツールです。しかし、銀の弾丸ではありません。向いている場面と不向きな場面を整理します。
向いている場面
Go標準の並行処理(チャネル、mutex、WaitGroup等)と時間操作(time.Sleep、time.After等)のテストに最適です。
タイムアウト処理
func TestTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
done := make(chan bool)
select {
case <-done:
t.Error("should timeout")
case <-time.After(1 * time.Hour):
// 1時間のタイムアウトが一瞬で完了
}
})
}
複数goroutineの完了待ち
func TestMultipleGoroutines(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
var wg sync.WaitGroup
count := 0
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
time.Sleep(10 * time.Minute)
count++
}()
}
wg.Wait()
if count != 5 {
t.Errorf("expected 5, got %d", count)
}
})
}
不向きな場面
実際のI/O操作を伴う処理には向いていません。これらはbubble外の要因でブロックが解除されるため、synctestでは制御できません。
例)HTTPサーバー・クライアントのテスト
HTTP通信は、synctestで扱えないI/O操作の典型例です。そのため、ここで詳しく見ていきます。
Webアプリケーション開発でよくあるHTTPクライアントのテストを書く場合、httptest.NewServerをつかうのでは無いでしょうか?
タイムアウト処理のテストですが、httptest.NewServerを使ったテストも、外部APIへのアクセスも、synctestでは扱えません:
func TestHTTP(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// httptest.NewServer
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
}))
defer server.Close()
// 処理が完了しない
resp2, err := http.Get(server.URL)
})
}
これはなぜかというと、httptest.NewServerは内部で実際のTCPソケットを使っているためです。
HTTP通信ではnet/http/transport.goがソケットI/Oを待機しますが、この待機状態はwaitReasonIOWaitというnon-durably blockedに分類されます。
synctestは、goroutineスケジューラーの上に構築された監視・制御の仕組みです(詳細はruntime/synctest.go参照)。
goroutineがI/O待ちになると、スケジューラーからsynctestに状態変化が通知されます。ここで重要なのは、synctestはgoroutineがdurably blockedかどうかで判断を変えることです:
-
durably blockedなgoroutine:bubble内で解決できるブロック → 「このgoroutineは待っていてよい」と判断 -
non-durably blockedなgoroutine:bubble外の要因待ち → 「このgoroutineは実行可能」と判断
HTTP通信のようなI/O操作は後者に分類されます。そのため、synctestは「まだ実行可能なgoroutineがある」と判断して時間を進めません。しかし実際にはそのgoroutineは外部からのデータ待ちでブロックされているため、処理が一切進まなくなります。
回避策:net.Pipeを使う
実際のネットワーク通信の代わりに、net.Pipeを使ったインメモリ通信を使うことで、テストが可能になります。
公式ブログでは、HTTP 100 Continueのテスト例が紹介されています。以下は実際に動作するコード例です:
func TestHTTPTransport100Continue(t *testing.T) {
synctest.Test(t, func(*testing.T) {
// インメモリのネットワーク接続を作成
srvConn, cliConn := net.Pipe()
defer cliConn.Close()
defer srvConn.Close()
tr := &http.Transport{
// net.Pipeで作成した接続を使用
DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
return cliConn, nil
},
ExpectContinueTimeout: 5 * time.Second,
}
// リクエスト送信(別goroutineで実行)
body := "request body"
go func() {
req, _ := http.NewRequest("PUT", "http://test.tld/", strings.NewReader(body))
req.Header.Set("Expect", "100-continue")
resp, err := tr.RoundTrip(req)
if err != nil {
t.Errorf("RoundTrip: unexpected error %v\n", err)
} else {
resp.Body.Close()
}
}()
// サーバー側でリクエストヘッダーを読み取り
req, err := http.ReadRequest(bufio.NewReader(srvConn))
if err != nil {
t.Fatalf("ReadRequest: %v\n", err)
}
// ボディをコピーする処理を開始
var gotBody bytes.Buffer
go io.Copy(&gotBody, req.Body)
// 100 Continue前はボディが送信されないことを確認
synctest.Wait()
if got, want := gotBody.String(), ""; got != want {
t.Fatalf("before sending 100 Continue, read body: %q, want %q\n", got, want)
}
// 100 Continue送信後、ボディが送信されることを確認
srvConn.Write([]byte("HTTP/1.1 100 Continue\r\n\r\n"))
synctest.Wait()
if got, want := gotBody.String(), body; got != want {
t.Fatalf("after sending 100 Continue, read body: %q, want %q\n", got, want)
}
// レスポンスを返して完了
srvConn.Write([]byte("HTTP/1.1 200 OK\r\n\r\n"))
})
}
この例のポイント:
-
net.Pipeでインメモリ接続を作成:実際のネットワークI/Oを使わないため、synctestで制御可能 -
http.TransportでDialContextを上書き:通常の接続の代わりにnet.Pipeを使用 -
synctest.Wait()で状態を確認:すべてのgoroutineがブロックされた時点での状態を検証
net.Pipeを使うことで、ネットワーク通信のロジックをsynctestでテストできるようになります。
補足: HTTP周りの改善について
現在、httptestパッケージにsynctest対応を追加する提案が議論されています。
proposal: net/http/httptest: synctest support #76608
まだプロポーザル段階ですが、将来的にHTTPサーバーのテストとsynctestの組み合わせがより自然に書けるようになるかもしれません。気になる方はissueをウォッチしてみてください。
まとめ
testing/synctestは、並行処理のテストを決定論的に実行できる強力なツールです。いかがだったでしょうか?
並行処理の複雑なシナリオが、かなり簡潔に書けるようになったと思います。
特に待機系の処理が一瞬で終わるのは書いていて非常にきもちがよいものです。
特に、goroutineをはじめとしたGo標準の並行処理を使っている部分では、testing/synctestを活用することでテストが高速かつ安定します。長いタイムアウトを待つ必要もなくなり、time.Sleep()で調整するような不安定なテストからも卒業できます。
一方で、繰り返しになりますがI/O操作を含む場合は利用できないため、工夫が必要です。
例えば外部APIを使ったシステムのテストの場合:
- 正常系シナリオ:従来通り
httptestパッケージのテストユーティリティを使う - タイムアウトなどの異常系シナリオ:
net.Pipeを使ったインメモリ接続でhttp.Transportを構成する
このように、testing/synctestと従来のテスト手法を適切に組み合わせることで、複雑なシナリオをシンプルに保守性高く記述することが可能になります。
余談ですが、個人的にtesting/synctestの面白く感じたところとして、Goのruntimeに深く手を入れて実装されている点です。この記事を書くにあたり、goroutineのスケジューリングなど、普段は意識しないGoの低レイヤーの仕組みを知ることができました。
興味を持たれた方は、ぜひGoのruntime実装を読んでみてください。testing/synctestはその一部ですが、Goの内部実装を学ぶ良い入り口になると思います!
もしよかったら@glassmonekeyをフォローしていただけると喜びます。
また、弊社ではUbieでは絶賛採用活動中です。
参考資料
- testing/synctest - Go Package Documentation
- Go 1.25 Release Notes
- Go Blog: synctest - Testing Networked Code
- Proposal: testing/synctest - GitHub Issue #67434
- Proposal: API changes for synctest.Test - GitHub Issue #73567
- Proposal: net/http/httptest: synctest support - GitHub Issue #76608
- runtime/synctest.go
- net/http/transport.go
Discussion