Go 1.24で実験的に登場するsynctestって何者?
結論要約
time.Sleep(時が止まる関数)使った時間依存の処理のテストめちゃ時間かかるけど、synctest使うと時間依存無視してテストできるからみんなHappy!!!
synctestって何者?
proposal読んで僕はこう解釈しましたというゆるい感想文なので丸呑みは危険です。
優しいフィードバック待っています。(まさかりは勘弁してください)
WHY
なぜ必要なの?
ある問題に対しての何かしらの解決策があるから提案しているんだよね。
issue: 時間依存テストの課題
- キャッシュの有効期限: キャッシュに値を格納し、一定時間後に古い値を無効化する。値が無効化されるのに時間を待つ必要さ性がある。
- タイムアウト処理: ネットワーク接続や外部リソースへのアクセスで、一定時間待ってからタイムアウト判定を行う。
example cache
type Cache[K comparable, V any] struct{}
// NewCache creates a new cache with the given expiry.
// f is called to create new items as necessary.
func NewCache[K comparable, V any](expiry time.Duration, f func(K) V) *Cache {}
// Get returns the cache entry for K, creating it if necessary.
func (c *Cache[K,V]) Get(key K) V {}
example cache test
func TestCacheEntryExpires(t *testing.T) {
count := 0
c := NewCache(2 * time.Second, func(key string) int {
count++
return fmt.Sprintf("%v:%v", key, count)
})
// Get an entry from the cache.
if got, want := c.Get("k"), "k:1"; got != want {
t.Errorf("c.Get(k) = %q, want %q", got, want)
}
// Verify that we get the same entry when accessing it before the expiry.
time.Sleep(1 * time.Second)
if got, want := c.Get("k"), "k:1"; got != want {
t.Errorf("c.Get(k) = %q, want %q", got, want)
}
// Wait for the entry to expire and verify that we now get a new one.
time.Sleep(3 * time.Second)
if got, want := c.Get("k"), "k:2"; got != want {
t.Errorf("c.Get(k) = %q, want %q", got, want)
}
}
time.Sleepを駆使してキャッシュのexpireテストしている。最低でも4秒かかるテスト。
proposalでも
This test has a couple problems. It's slow, taking four seconds to execute. And it's flaky, because it assumes the cache entry will not have expired one second before its deadline and will have expired one second after. While computers are fast, it is not uncommon for an overloaded CI system to pause execution of a program for longer than a second.
2つの問題がある
遅くて
信頼性にかける(flakyはコーンフレーク的な感じ?ボロボロで不安定?)
と言ってる
solution: Synthetic Time(合成時間)で解決
synctestは、Synthetic Time(合成時間?)という概念を使ってテストの時間問題を解決するらしい。
合成時間は「2000年1月1日 00:00 UTC」から始まり(始まる時間は特に気にしなくて良いと思う)、実際のシステムクロックではなく、内部で管理される擬似的な時間。
この仕組みを利用し
-
テストの高速化:
time.Sleepやタイマー処理が実際に待たされるのではなく、合成時間がアイドル状態になったタイミングで一気に進むため、数秒待つ処理も即座に完了デキル。 -
テストの安定性向上:
合成時間を使うことで、実際の時間に左右されない確実なタイミングでの処理実行が可能になり、テストがフレークするリスクを低減デキル。
WHAT
結局何で何を解決するの?
主に以下の2つのAPIが提供され、Synthetic Timeを作る
synctest.Run
synctest.Runは、渡された関数を新たなゴルーチンで実行(ここで実行されるゴルーチンはSynthetic Timeの別世界、go界隈ではバブルが正式名称らしい)
このとき、その関数内でさらに起動されるゴルーチンも同じ「グループ」として管理される。
また、このグループ内では、time.Sleepやタイマーは合成時間で処理されるため、実際の待ち時間が発生しない(別世界だからね)
要はsynctest.Runに渡された処理(goroutine)は時間を容易に支配できる別次元で実行されているから
synctest.Wait
synctest.Waitは、グループ内の全てのゴルーチンがアイドル状態になるまでブロックします。
ここでいう「アイドル」とは、チャネル待ち、ミューテックス待ち、time.Sleep待ち、またはselect文で待機している状態を指すらしい。
これにより、非同期処理の完了を確実に待ってから次の検証を行うことができるのです😀
標準パッケージテストコード
func TestWait(t *testing.T) {
synctest.Run(func() {
done := false
ch := make(chan int)
var f func()
f = func() {
count := <-ch
if count == 0 {
done = true
} else {
go f()
ch <- count - 1
}
}
go f()
ch <- 100
synctest.Wait()
if !done {
t.Fatalf("done = false, want true")
}
})
}
※システムコール・cgo呼び出し,I/O操作,mutexesは「アイドル」ではないそう。(へぇ〜)
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
HOW(具体的な使用例)
先ほどのキャッシュのテストコードがsynctest使うと下記になる(コードは全てproposalから抜粋)
func TestCacheEntryExpires(t *testing.T) {
synctest.Run(func() {
count := 0
c := NewCache(2 * time.Second, func(key string) int {
count++
return fmt.Sprintf("%v:%v", key, count)
})
// 最初のキャッシュ生成: "k:1" が返される
if got, want := c.Get("k"), "k:1"; got != want {
t.Errorf("c.Get(k) = %q, want %q", got, want)
}
// 合成時間上で1秒待つ。実際にはtime.Sleepは待たず即座に進む
time.Sleep(1 * time.Second)
synctest.Wait() // ゴルーチンが全てアイドル状態になるまで待機
if got, want := c.Get("k"), "k:1"; got != want {
t.Errorf("c.Get(k) = %q, want %q", got, want)
}
// 合成時間上でさらに3秒待つ → 合成時間が一気に進み、キャッシュが失効する
time.Sleep(3 * time.Second)
synctest.Wait()
if got, want := c.Get("k"), "k:2"; got != want {
t.Errorf("c.Get(k) = %q, want %q", got, want)
}
})
}
大まかな処理フロー(見にくくてすいません)
WHEH WHERE
結局のところ、いつ、どこで使えばいんかい!?
時間依存のテスト全般:
キャッシュの有効期限、タイムアウト処理、リトライロジックなど、時間の経過に依存するコードのテストに有効
並行処理の同期:
ゴルーチン間で非同期に実行される処理の完了を正確に検証する必要がある場合、synctest.Waitを使ってすべてのゴルーチンがアイドル状態になるのを待つことで、タイミングのずれによるテストの不具合を防止できる
CI環境での安定したテスト:
実際の時間待ちに起因するテストの不安定さ(フレーク)を回避するため、合成時間により外部環境に左右されないテストが可能になるみたい
参考資料
Discussion