Goの並行処理をテストすると、タイムアウトやリトライ間隔の待ちがそのまま実時間になります。time.Sleepで失効や期限を待つテストは、CIで赤くなったり数秒固まったり。Go 1.25.0でGAになったtesting/synctestは、この実時間への依存をfake clockで断ちます。
time.Sleepに頼った並行テストが遅くて不安定になる理由
キャッシュの有効期限を確かめるテストを考えます。50ミリ秒で失効するエントリが、本当に消えているか。素直に書くと、実時間を待つことになります。
タイムアウトを実時間で待つコスト
func TestCacheExpiry_Sleep(t *testing.T) {
c := NewCache(50 * time.Millisecond)
c.Set("k", "v")
time.Sleep(60 * time.Millisecond) // 失効を実時間で待つ
if _, ok := c.Get("k"); ok {
t.Fatal("失効したはずのキーが残っている")
}
}
$ go test -v -run TestCacheExpiry_Sleep
=== RUN TestCacheExpiry_Sleep
--- PASS: TestCacheExpiry_Sleep (0.06s)
PASS
1本で0.06秒。失効パターンが10種類あれば0.6秒。リトライやバックオフのテストが増えるほど、テストスイート全体が秒単位で重くなります。公式ブログ Testing concurrent code with testing/synctest の冒頭 “Testing concurrent programs is difficult” でも、この遅さと不安定さは表裏一体だと書かれています。
We can make the test less flaky at the expense of making it slower, and we can make it less slow at the expense of making it flakier, but we can’t make it both fast and reliable.
遅さを削るとflakyになり、flakyを抑えると遅くなる。両立はできない、という指摘です。
CIでだけ落ちるflakyの正体
60ミリ秒のスリープは手元のマシンでは確実に失効後に着地します。ただ共有CIではGCの停止やスケジューラの遅延で、Setからの経過が想定とずれる。リトライ間隔のテストが月に数回だけCIで赤くなり、再実行すると緑に戻る、という状態を放置していた時期があります。原因は実時間に依存していたことです。fake clockならこのブレが消えます。
bubbleとfake clockで時間を支配する
testing/synctestの中心にあるのがbubbleという隔離環境です。公式は次のように定義しています。
This goroutine and any goroutines started by it exist in an isolated environment which we call a bubble.
bubbleの中だけ時間が別に進む
bubble内ではtimeパッケージがfake clockに差し替わります。pkg.go.devの “Time” セクションによると、各bubbleは独自のクロックを持ち、初期時刻はmidnight UTC 2000-01-01固定。実時間とは完全に切り離されます。
全goroutineが止まると時間が進む
fake clockは勝手には進みません。”Time” セクションの記述が条件を一言で表しています。
Time in a bubble only advances when every goroutine in the bubble is durably blocked.
bubble内の全goroutineがdurably blocked(永続的にブロック)になった瞬間だけ、クロックは次にgoroutineを動かせる時刻まで一気にジャンプします。だから5秒のtime.Sleepも、実時間を1ミリ秒も消費せずに「5秒経過した状態」を作れます。
context.WithTimeoutを一瞬でテストする
仕組みより先にコードです。5秒のタイムアウトを持つcontextが、境界の前後で正しく振る舞うかを検証します。実時間で書けば5秒かかるテストが、synctestでは即座に終わります。
synctest.Testでテスト全体をbubbleに包む
package cache
import (
"context"
"testing"
"testing/synctest"
"time"
)
func TestWithTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
const timeout = 5 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// タイムアウト直前(残り1ns)まで時間を進める
time.Sleep(timeout - time.Nanosecond)
synctest.Wait()
if err := ctx.Err(); err != nil {
t.Fatalf("タイムアウト前: ctx.Err() = %v; want nil", err)
}
// 残りの1nsを進めてタイムアウトさせる
time.Sleep(time.Nanosecond)
synctest.Wait()
if err := ctx.Err(); err != context.DeadlineExceeded {
t.Fatalf("タイムアウト後: ctx.Err() = %v; want DeadlineExceeded", err)
}
})
}
$ go test -v -run TestWithTimeout
=== RUN TestWithTimeout
--- PASS: TestWithTimeout (0.00s)
PASS
ok example/cache 0.20s
5秒のタイムアウトを境界の前後2回検証して、テスト本体の所要は0.00s。time.Sleepはfake clockを進めるだけで、実時間のスリープには変換されません。synctest.Testに渡した関数が1つのbubbleになり、その中で起動したgoroutineもすべて同じクロックを共有します。
synctest.Waitで処理が落ち着くのを待つ
境界判定の鍵がsynctest.Wait()です。pkg.go.devの関数ドキュメントはこう書いています。
Wait blocks until every goroutine within the current bubble, other than the current goroutine, is durably blocked.
context.WithTimeoutは内部でgoroutineを1本動かし、期限が来るとcancelを呼んでctx.Err()を更新します。time.Sleepでクロックを進めただけでは、その内部goroutineの処理が完了したかは保証されません。Waitを挟むと、自分以外のgoroutineがすべてdurably blockedに落ち着くまで待ってから次の行へ進む。だからctx.Err()を読むタイミングが決定的になり、レースが入り込む隙が消えます。Waitを外すとDeadlineExceededの検証がたまに失敗する、というのがこのテストで一番つまずきやすい点です。
durably blockedで時間が進む条件を見極める
「全goroutineがdurably blocked」が分かりにくいので、何がそれに該当するかを整理します。公式ブログの “Blocking and the bubble”、”Mutexes”、”I/O” の各セクションをまとめると次の通りです。
| 操作 | durably blockedになるか |
|---|---|
| bubble内で作ったchannelの送受信 | なる |
全caseがbubble内channelのselect | なる |
time.Sleep / time.After | なる |
sync.Cond.Wait | なる |
sync.WaitGroup.Wait(Addもbubble内) | なる |
sync.Mutex.Lockでのロック待ち | ならない |
| ネットワーク / ファイルI/O | ならない |
“Mutexes” セクションは、ミューテックスのロック待ちは原理的に短時間で解放されるためdurably blockedとは扱わない、と説明しています。”I/O” セクションも同様で、外界とのI/Oはbubbleの外の事象で解除されうるため対象外。実ネットワーク越しの待ちをbubbleに入れても時間は進まず、デッドロック扱いでTestがpanicします。
Go 1.24からの移行で踏む2つの段差
synctestはGo 1.24で実験的に入り、Go 1.25でGAに昇格しました。この間にAPIと有効化方法が変わっています。1.24時代の記事や公式ブログのコードをそのまま貼ると動きません。
synctest.RunはTestに改名された
1.24のsynctest.Run(func())は、1.25でsynctest.Test(t *testing.T, f func(*testing.T))に変わりました。新しいTestはbubbleの生存期間にスコープした*testing.Tを渡すため、t.Cleanupがbubbleを抜けた後ではなくbubbleの中で走ります。
// Go 1.24 (実験的・現在は動かない)
synctest.Run(func() {
// ...
})
// Go 1.25以降 (GA)
synctest.Test(t, func(t *testing.T) {
// ...
})
GOEXPERIMENT=synctestはもう要らない
1.24ではGOEXPERIMENT=synctestを付けてビルドする必要がありました。GA後の1.25以降は不要で、むしろ付けるとエラーになります。
$ GOEXPERIMENT=synctest go test ./...
go: unknown GOEXPERIMENT synctest
環境変数を外し、import "testing/synctest"を書くだけで使えます。Go 1.25.0以降、執筆時点の最新はgo1.26.3(Go 1.26は2026年2月リリース)。古い手順を引きずっていたら、まずこのフラグを消してください。
まとめ
- testing/synctestはGo 1.25.0でGA、現行はgo1.26.3で安定版
- bubble内ではfake clockが動き、
time.Sleepは実時間を消費しない(初期時刻はmidnight UTC 2000-01-01) - クロックは全goroutineがdurably blockedになった瞬間だけ進む
synctest.Waitで他goroutineの落ち着きを待ってから検証すると、判定が決定的になるsync.Mutexのロック待ちや実ネットワークI/Oはdurably blockedにならない- 1.24の
synctest.RunはTestに改名、GOEXPERIMENT=synctestは不要
時間に依存する並行処理のテストは、bubble内のfake clockで遅さとflakyの両方が取れます。実時間を待っているテストから順に置き換えると、CIの実行時間と赤色の頻度が同時に減ります。

