【Go1.24】testing/synctestパッケージの使い方をめちゃくちゃ丁寧に説明してみた!
導入
Go1.24 が 2025 年 2 月にリリースされました 🎉🎉🎉
型エイリアスがジェネリクスに対応したり、map の実装が Swiss Table になったりとさまざまな改善が行われました!
今回はこの中から、実験的に導入された testing/synctest パッケージについて紹介したいと思います!
この記事では、testing/synctest で実装された 2 つの API を紹介しつつ、GoDoc に記載されている具体的な仕様についても触れたいと思っています。
めちゃくちゃ丁寧に説明していると思うので「testing/synctest パッケージがよくわからんな〜」と思っている人のためになればと思います!🙏
実装された背景
そもそもなぜ、testing/synctest パッケージが導入されたのでしょうか?
「並列なプログラムのテストを行うことは時間がかかり、flakly なテストになりがち」 という課題を解決するために実装された、というのが回答になります!
flaky なテストとは、実行時にランダムで落ちてしまうテストのことです。
この後の章で、実際に時間がかかるテストを書いてみたり、flaky なテストを再現してみようと思っています。
そして、実際に testing/synctest を導入することで、上記の課題が解決することも確かめてみたいと思います 🙌
実際に試してみる!
今回のコードで使用する Go のバージョンは 1.24.0 になります。
❯ go version
go version go1.24.0 darwin/arm64
また VSCode で実際に試してみたい人は、下記の設定を setting.json
に追加してみてください 🙆♂️
{
"go.toolsEnvVars": {
"GOEXPERIMENT": "synctest"
}
}
実験 1: 簡単なキャッシュ
まずは下記のような簡単なキャッシュの例で考えてみます。
package main
import (
"time"
)
func NewCache[T any]() *Cache[T] {
return &Cache[T]{}
}
type Cache[T any] struct {
v T
ttl time.Duration
setTime time.Time
}
func (c *Cache[T]) Set(value T, ttl time.Duration) {
c.v = value
c.ttl = ttl
c.setTime = time.Now()
}
func (c *Cache[T]) Get() T {
if time.Since(c.setTime) >= c.ttl {
var zero T
return zero
}
return c.v
}
Set
にキャッシュしたい値と TTL を渡します。Get
が呼ばれたとき、TTL で指定した時間が経過していた場合はゼロ値を返し、経過する前の場合はキャッシュした値を返します。
あえて時間がかかるテストを書きたいので、TTL を 5 秒とし、テストを以下のように書いてみます。
package main
import (
"testing"
"time"
)
func TestCache_Get(t *testing.T) {
ttl := 5 * time.Second
cache := NewCache[string]()
cache.Set("cached item", ttl)
if got := cache.Get(); got != "cached item" {
t.Errorf("expected 'cached item'; got %v", got)
}
time.Sleep(ttl)
if got := cache.Get(); got != "" {
t.Errorf("expected ''; got %v", got)
}
}
実行してみます!
❯ GOEXPERIMENT=synctest go test -run "TestCache_Get"
PASS
ok github.com/k3forx/go124 5.229s
当たり前ですが、5 秒以上かかるテストになっています 😢
Run
関数を使ってテスト時間を短くする
ここで登場するのが testing/synctest パッケージの Run
関数です!
Run
関数で先ほどのテストコードを Wrap するだけです。
func TestCache_Get(t *testing.T) {
synctest.Run(func() { // Run関数でテストをWrapする
ttl := 5 * time.Second
cache := NewCache[string]()
cache.Set("cached item", ttl)
if got := cache.Get(); got != "cached item" {
t.Errorf("expected 'cached item'; got %v", got)
}
time.Sleep(ttl)
if got := cache.Get(); got != "" {
t.Errorf("expected ''; got %v", got)
}
})
}
実行してみます!
❯ GOEXPERIMENT=synctest go test -run "TestCache_Get"
PASS
ok github.com/k3forx/go124 0.245s
実行時間が 5.229s
から 0.245s
に減りました!🎉🎉🎉
Run
関数の GoDoc を見てみる!
さて、Run
関数はどのような挙動になっているのでしょうか?
(どういうロジックによって、テストの時間が短縮されるのでしょうか?)
GoDoc を見てみましょう 👀
❯ GOEXPERIMENT=synctest go doc testing/synctest.Run
package synctest // import "testing/synctest"
func Run(f func())
Run executes f in a new goroutine.
The new goroutine and any goroutines transitively started by it form an
isolated "bubble". Run waits for all goroutines in the bubble to exit before
returning.
Goroutines in the bubble use a synthetic time implementation. The initial
time is midnight UTC 2000-01-01.
Time advances when every goroutine in the bubble is blocked. For example,
a call to time.Sleep will block until all other goroutines are blocked and
return after the bubble's clock has advanced. See Wait for the specific
definition of blocked.
If every goroutine is blocked and there are no timers scheduled, Run panics.
Channels, time.Timers, and time.Tickers created within the bubble are
associated with it. Operating on a bubbled channel, timer, or ticker from
outside the bubble panics.
筆者が一部を翻訳してみました。
-
Run
は関数f
を新しいゴルーチンの中で実行する - 新しいゴルーチンとそれから間接的に開始されたゴルーチンは bubble を形成する
-
Run
は bubble の中のすべてのゴルーチンが終了するまで待つ - bubble の中のゴルーチンは synthetic な時間実装を使う、初期時間は UTC の 2000-01-01
- bubble の中のすべてのゴルーチンがブロックされたときに時間が進む
- すべてのゴルーチンがブロックされ、タイマーがスケジュールされていないなら、
Run
は panic する - bubble の中で作成されたチャネルや
time.Timers
、time.Ticker
は bubble に紐づく - bubble の外から、bubble 内のチャネルや timer, ticker を操作すると panic になる
すべての仕様を確認することはせず、とくに 4, 5 の仕様に絞って動作を確認してみます!
太字で強調している部分もポイントになります 💡
4, 5 の仕様を確かめてみます!
下記のように time.Sleep
を仕込んでみて、時刻を出力してみます。
func TestCache_Get(t *testing.T) {
synctest.Run(func() {
fmt.Println(time.Now()) // 現在時刻を出力
ttl := 5 * time.Second
cache := NewCache[string]()
cache.Set("cached item", ttl)
if got := cache.Get(); got != "cached item" {
t.Errorf("expected 'cached item'; got %v", got)
}
fmt.Println(time.Now()) // 現在時刻を出力
time.Sleep(ttl)
fmt.Println(time.Now()) // 現在時刻を出力
if got := cache.Get(); got != "" {
t.Errorf("expected ''; got %v", got)
}
fmt.Println(time.Now()) // 現在時刻を出力
})
}
実行してみます。
❯ GOEXPERIMENT=synctest go test -run "TestCache_Get"
2000-01-01 09:00:00 +0900 JST m=+946640561.917543876
2000-01-01 09:00:00 +0900 JST m=+946640561.917543876
2000-01-01 09:00:05 +0900 JST m=+946640566.917543876
2000-01-01 09:00:05 +0900 JST m=+946640566.917543876
PASS
ok github.com/k3forx/go124 0.251s
以下のことがわかりました 🔍
- 2 回目までの時刻は
2000-01-01 09:00:00 (JST)
となっており、3 回目以降の時刻は2000-01-01 09:00:05 (JST)
になっている-
Run
関数の 4 番目の仕様である 「bubble の中のゴルーチンは synthetic な時間実装を使う、初期時間は UTC の 2000-01-01」 と一致
-
- 1 回目と 2 回目の時刻、3 回目と 4 回目の時刻は同一時刻になっている
-
Run
関数の 5 番目の仕様である 「bubble の中のすべてのゴルーチンがブロックされたときに時間が進む」 と一致
-
2 番目に関して少し補足します。
今回の場合、bubble の中で生成されるゴルーチンは 1 つになります。time.Sleep(ttl)
によってそのゴルーチンがブロックされることにより、「bubble の中のすべてのゴルーチンがブロックされた」状態になります。
そのような状態になって はじめて 時刻が進むようになります。なので、1 回目と 2 回目の時刻は同じ時刻になります。
time.Sleep(ttl)
が終了した後はどうでしょうか?
その後、 ゴルーチンがブロックされることはないので、時刻は進みません。 なので、3 回目と 4 回目の時刻が同じになります。
ここで疑問 🤔
👦「別に TTL を 5 秒にしなくても、1 マイクロ秒にすればいいやん」
→ その通りです!今回の実装の場合 testing/synctest パッケージは不要です。
→ なぜなら 実装が直列で実行 され、TTL を短くすればそこまで長いテストにならないからです。
次はもう少し複雑な実装を考えてみます!
実験 1 で分かったことをまとめる
いったん、ここまでで分かったことをまとめてみました!
- 時間がかかるテストを
synctest.Run
関数を使うと短縮できる -
Run
は与えられた関数f
を bubble の中で実行する - bubble 内のすべてのゴルーチンがブロックされたときに時刻が進む
- 時刻の初期値は 2000-01-01 (UTC)
とくに 3 番目の Run
の仕様は、個人的にとても重要だと思います。
実験 2: 簡単なキャッシュ v2
では、Set
を呼び出した際にゴルーチンを使って、キャッシュを expire する(ゼロ値に更新する)実装を考えてみます!
以下のようにコードを変更します。
package main
import (
"time"
)
func NewCache[T any]() *Cache[T] {
return &Cache[T]{}
}
type Cache[T any] struct {
v T
ttl time.Duration
}
func (c *Cache[T]) Set(value T, ttl time.Duration) {
c.v = value
c.ttl = ttl
go func() {
var zero T
time.Sleep(ttl)
c.v = zero
}()
}
func (c *Cache[T]) Get() T {
return c.v
}
テストコードは先ほどとまったく同じものを使用し、100 回実行してみます。
❯ GOEXPERIMENT=synctest go test -run "TestCache_Get" -count=100
--- FAIL: TestCache_Get (0.00s)
synctest_test.go:22: expected ''; got cached item
--- FAIL: TestCache_Get (0.00s)
synctest_test.go:22: expected ''; got cached item
--- FAIL: TestCache_Get (0.00s)
synctest_test.go:22: expected ''; got cached item
--- FAIL: TestCache_Get (0.00s)
synctest_test.go:22: expected ''; got cached item
FAIL
exit status 1
FAIL github.com/k3forx/go124 0.246s
4 回落ちました。flaky なテストになっているようです 😢
なぜテストが落ちるのか?
原因は 「Get
と c.v = zero
のどちらが先に実行されるか分からない」 からです。
処理の流れを明確にするため、簡易的な図を用意してみました。
左がテストを実行するメインのゴルーチン、右が Set
を呼び出したときに生成されるゴルーチン(サブのゴルーチン)を表しています。つまり、このテストでは全部で 2 つ のゴルーチンが存在しています。
処理の流れとしては、以下のようになっています。
- メインのゴルーチンで
Set
が呼ばれる - サブのゴルーチンが作成され、
time.Sleep(ttl)
でブロックされる - メインのゴルーチンで
Get
が呼ばれる - メインのゴルーチンで
time.Sleep(ttl)
が呼ばれ、ブロックされる -
Run
関数により、時刻が進む - メインのゴルーチンとサブのゴルーチンのブロックが解除される
-
Get
が呼ばれる?c.v = zero
が呼ばれる?
flaky になる原因は、「最後のステップで、Get
と c.v = zero
の実行順序が不確定であるため」 です。
もう 1 つポイントがあります。
テスト内での時刻が進むのは 「メインのゴルーチンとサブのゴルーチンの 2 つすべてがブロックされたとき」 ということです。これは Run
の「bubble の中のすべてのゴルーチンがブロックされたときに時間が進む」という仕様が関係しています 💡
Wait
関数を使って flaky なテストを直す
先ほどの flaky なテストを直すためには実行順序が担保されている必要があります。
つまり「Get
が呼ばれる前に c.v = zero
が呼ばれること」が必要です!
このことは Wait
関数を用いることで実現できるようになっています。具体的には以下のようなテストコードに修正します。
func TestCache_Get(t *testing.T) {
synctest.Run(func() {
ttl := 5 * time.Second
cache := NewCache[string]()
cache.Set("cached item", ttl)
if got := cache.Get(); got != "cached item" {
t.Errorf("expected 'cached item'; got %v", got)
}
time.Sleep(ttl)
synctest.Wait() // Getを呼ぶ前にWaitを呼ぶ
if got := cache.Get(); got != "" {
t.Errorf("expected ''; got %v", got)
}
})
}
100 回実行してみます。
❯ GOEXPERIMENT=synctest go test -run "TestCache_Get" -count=100
PASS
ok github.com/k3forx/go124 0.169s
テストが落ちなくなりました 🎉🎉🎉
直感的には 「Wait
関数はゴルーチンの終了を待つ」 というような挙動に見えますが、本当にそうなのでしょうか?
Wait
関数の GoDoc を見てみる
GoDoc を見てみます 👀
❯ GOEXPERIMENT=synctest go doc testing/synctest.Wait
package synctest // import "testing/synctest"
func Wait()
Wait blocks until every goroutine within the current bubble, other than
the current goroutine, is durably blocked. It panics if called from a
non-bubbled goroutine, or if two goroutines in the same bubble call Wait at
the same time.
A goroutine is durably blocked if can only be unblocked by another goroutine
in its bubble. The following operations durably block a goroutine:
- a send or receive on a channel from within the bubble
- a select statement where every case is a channel within the bubble
- sync.Cond.Wait
- time.Sleep
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 an 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.
A goroutine is not durably blocked when blocked on a send or receive on a
channel that was not created within its bubble, because it may be unblocked
by a channel receive or send from outside its bubble.
筆者が一部を翻訳してみました。
-
Wait
は、現在の bubble 内の現在のゴルーチン以外のすべてのゴルーチンが持続的にブロック (durably block) されるまでブロックする - bubble 外のゴルーチンから呼び出された場合や、同じ bubble 内の 2 つのゴルーチンが同時に
Wait
を呼び出した場合はパニックする - bubble 内の他のゴルーチンによってのみブロックを解除できる場合、そのゴルーチンは durably block される
-
time.Sleep
や bubble 内からのチャネルの送受信などはゴルーチンを durably block する
ここで大事なのは 「Wait
関数がゴルーチンの終了を待つわけではない」 ということです(気になるかたは原文の英語を読んでもらえると良いと思います)。
さらに 1 の仕様もポイントです 💡
Wait
は自身が呼ばれたゴルーチン以外のすべてのゴルーチンが durably block になるまで、自身が呼ばれたゴルーチンをブロックする、ということです。
また言い換えると 「自身以外のすべてのゴルーチンが durably block になったとき、自身のゴルーチンを unblock する」 と解釈できます。
Wait
関数の挙動
処理の流れを明確にするため、簡易的な図を用意してみました。先ほどの図と同じように
- 左がテストを実行するメインのゴルーチン
- 右が
Set
を呼び出したときに生成されるゴルーチン(サブのゴルーチン)
を表しています。
メインとサブのゴルーチンの両方がブロックされたのちに時刻が進む、というところまでは、先ほどと同じ流れになります。それ以降は以下のように処理が進みます。
-
Wait
が呼ばれたとき、サブのゴルーチンは実行中なら 2、終了したなら 4 へ - サブのゴルーチンが durably block になるまで待つ
- durably block になることなく、サブのゴルーチンが終了
-
Wait
はサブのゴルーチンを気にする必要がなくなって unblock - メインのゴルーチンの処理が進む
今回のケースでは 「サブのゴルーチンが durably block になるのを待っていると、durably block になることなくサブのゴルーチンが終了してしまった」 ということになります。
このような挙動が「ゴルーチンの終了を待っているような挙動に見える」わけですね!
Go クイズ!
Run
関数と Wait
関数の挙動がざっくりと分かったところで、2 問ほど Go クイズを考えてみました!
答えを見る前に皆さんも考えてもらえると、より理解が深まると思います 🙋
Set
だけ呼んだとき、下記で出力される時刻はどうなるか?
Q1. テストで main.go
// Cacheの構造体は上記の実験1と2で使用したものと全く同じ
func (c *Cache[T]) Set(value T, ttl time.Duration) {
c.v = value
c.ttl = ttl
go func() {
var zero T
time.Sleep(ttl)
c.v = zero
fmt.Println(time.Now()) // 現在時刻を出力
}()
}
main_test.go
func TestCache_Set(t *testing.T) {
synctest.Run(func() {
ttl := 5 * time.Second
cache := NewCache[string]()
cache.Set("cached item", ttl)
})
}
Run
関数の中では Set
のみを呼んでいます。この場合、main.go
で呼ばれている time.Now()
の出力はどうなるでしょうか?
- テストを実行したときの現在時刻
- 2000-01-01 00:00:00 (UTC)
- 2000-01-01 00:00:05 (UTC)
実際に実行してみたいと思います!
❯ GOEXPERIMENT=synctest go test -run "TestCache_Set"
2000-01-01 09:00:05 +0900 JST m=+946679978.114641168
PASS
ok github.com/k3forx/go124 0.245s
正解は3です!🎉
こちらも簡易的な図を使って、実際の挙動を解説してみます。
-
Set
からサブのゴルーチンが作成される - メインのゴルーチンがブロックされる
- 「
Run
は bubble の中のすべてのゴルーチンが終了するまで待つ」というRun
の仕様があるため
- 「
- サブのゴルーチンが
time.Sleep
でブロックされる - メインとサブのゴルーチンがブロックされた状態になるので、時刻が進む
-
fmt.Println(time.Now())
が実行される - サブのゴルーチンが終了する
- メインのゴルーチンが終了する
ここでポイントになるのが 「Run
は bubble の中のすべてのゴルーチンが終了するまで待つ」 という Run
の仕様です 💡
この仕様があるため、Run
はその中身の実行が終わっても、即時にテストを終了することはありません。Run
の中で実行されている すべてのゴルーチンの終了を待ったのちに、テストを終了する ような挙動になります。
time.Sleep
を外すとどうなるか?
Q2. テストのメインのゴルーチンの さて、2 つ目の Go クイズは実験 2 で使用したテストコードから time.Sleep
を外した以下のコードの挙動についてです!
func TestCache_Get(t *testing.T) {
synctest.Run(func() {
ttl := 5 * time.Second
cache := NewCache[string]()
cache.Set("cached item", ttl)
if got := cache.Get(); got != "cached item" {
t.Errorf("expected 'cached item'; got %v", got)
}
// time.Sleep(ttl) // 呼ばないようにする
synctest.Wait()
if got := cache.Get(); got != "" {
t.Errorf("expected ''; got %v", got)
}
})
}
上記のテストコードを 100 回実行したときの結果はどうなるでしょうか?
- 100 回すべて失敗する(テストが落ちる)
- 何回かしか成功しない(flaky なテストになる)
- 100 回すべて成功する(テストは落ちない)
Wait
はゴルーチンの終了を待つわけではない、ということを思い出していただくと良いかもしれません。
実際に確かめてみます!
❯ GOEXPERIMENT=synctest go test -run "TestCache_Get" -count=100 | grep 'FAIL:' | wc -l
100
正解は 1 です!🎉
簡易的な図を使って説明してみます(今回の図はあまり役立たないかもしれません)。
-
Set
からサブのゴルーチンが作成される -
Get
が呼ばれる -
Wait
が呼ばれる - サブのゴルーチンが
time.Sleep
によって durably block される -
Wait
が解除される -
Get
が呼ばれる - メインのゴルーチンがサブのゴルーチンの処理待ちによりブロックされる
- メインとサブのゴルーチンの 2 つすべてのゴルーチンがブロックされ、時刻が進む
-
c.v = zero
が実行され、サブのゴルーチンが終了 - サブのゴルーチンの終了を待って、メインのゴルーチンが終了
となります。
3, 4 はどちらが先に実行されるかは不明ですが、重要なのは 「サブのゴルーチンが durably block になった状態であれば、Wait
はその時点で unblock する」 ということです。
これは Wait
の仕様である「Wait
は、現在の bubble 内の現在のゴルーチン以外のすべてのゴルーチンが持続的にブロック (durably block) されるまでブロックする」を言い換えたものになります!
また、サブのゴルーチンの time.Sleep
によって時刻が進むのは 「すべてのゴルーチンがブロックされたとき = メインのゴルーチンがサブのゴルーチンの終了待ちになったとき」 になります。
これらの解釈には以下の Run
と Wait
の仕様が関係しています。
-
Run
の仕様- bubble の中のすべてのゴルーチンが終了するまで待つ(A)
- bubble 内のすべてのゴルーチンがブロックされたときに時刻が進む(B)
-
Wait
の仕様-
time.Sleep
や bubble 内からのチャネルの送受信などはゴルーチンを durably block する(A) - bubble 内の他のゴルーチンによってのみブロックを解除できる場合、そのゴルーチンは durably block される(B)
-
まず、サブのゴルーチンが time.Sleep
によって durably block されます(Wait
の仕様 A)。メインのゴルーチンのすべての処理が終了し、サブのゴルーチンが終了しないと終われない状態になります(Run
の仕様 A)。
Wait
の仕様 B により、メインのゴルーチンがサブのゴルーチンによってのみブロックが解除される状態になります(メインのゴルーチンが durably block された状態になる)。
この時点で、Run
の仕様 B により時刻が進みます。
(自分で問題作っておきながらなんですが、難しい挙動をしてますね w)
まとめ
いかがだったでしょうか?
Go1.24 で実験的に導入された testing/synctest の 2 つの公開 API である Run
関数、Wait
関数の挙動が少し分かったのではないでしょうか 🙌
先んじてこの内容を社内に向けて公開したところ「exponential backoff でリトライしているコードのテストに使えるのでは?」というフィードバックをいただきました。
次回はもう少し実用的なコードのテストを testing/synctest を使って書いてみたいと思っています!
以上、ここまでお読みいただきありがとうございました!!
Discussion