Zenn
🚰

【Go1.24】testing/synctestパッケージの使い方をめちゃくちゃ丁寧に説明してみた!

2025/03/28に公開
2

導入

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.

筆者が一部を翻訳してみました。

  1. Run は関数 f を新しいゴルーチンの中で実行する
  2. 新しいゴルーチンとそれから間接的に開始されたゴルーチンは bubble を形成する
  3. Run は bubble の中のすべてのゴルーチンが終了するまで待つ
  4. bubble の中のゴルーチンは synthetic な時間実装を使う、初期時間は UTC の 2000-01-01
  5. bubble の中のすべてのゴルーチンがブロックされたときに時間が進む
  6. すべてのゴルーチンがブロックされ、タイマーがスケジュールされていないなら、Run は panic する
  7. bubble の中で作成されたチャネルや time.Timerstime.Ticker は bubble に紐づく
  8. 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

以下のことがわかりました 🔍

  1. 2 回目までの時刻は 2000-01-01 09:00:00 (JST) となっており、3 回目以降の時刻は 2000-01-01 09:00:05 (JST) になっている
    • Run 関数の 4 番目の仕様である 「bubble の中のゴルーチンは synthetic な時間実装を使う、初期時間は UTC の 2000-01-01」 と一致
  2. 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 で分かったことをまとめる

いったん、ここまでで分かったことをまとめてみました!

  1. 時間がかかるテストを synctest.Run 関数を使うと短縮できる
  2. Run は与えられた関数 f を bubble の中で実行する
  3. bubble 内のすべてのゴルーチンがブロックされたときに時刻が進む
  4. 時刻の初期値は 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 なテストになっているようです 😢

なぜテストが落ちるのか?

原因は Getc.v = zero のどちらが先に実行されるか分からない」 からです。

処理の流れを明確にするため、簡易的な図を用意してみました。
左がテストを実行するメインのゴルーチン、右が Set を呼び出したときに生成されるゴルーチン(サブのゴルーチン)を表しています。つまり、このテストでは全部で 2 つ のゴルーチンが存在しています。

処理の流れとしては、以下のようになっています。

  1. メインのゴルーチンで Set が呼ばれる
  2. サブのゴルーチンが作成され、 time.Sleep(ttl) でブロックされる
  3. メインのゴルーチンで Get が呼ばれる
  4. メインのゴルーチンで time.Sleep(ttl) が呼ばれ、ブロックされる
  5. Run 関数により、時刻が進む
  6. メインのゴルーチンとサブのゴルーチンのブロックが解除される
  7. Get が呼ばれる? c.v = zero が呼ばれる?

flaky になる原因は、「最後のステップで、Getc.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.

筆者が一部を翻訳してみました。

  1. Wait は、現在の bubble 内の現在のゴルーチン以外のすべてのゴルーチンが持続的にブロック (durably block) されるまでブロックする
  2. bubble 外のゴルーチンから呼び出された場合や、同じ bubble 内の 2 つのゴルーチンが同時に Wait を呼び出した場合はパニックする
  3. bubble 内の他のゴルーチンによってのみブロックを解除できる場合、そのゴルーチンは durably block される
  4. time.Sleep や bubble 内からのチャネルの送受信などはゴルーチンを durably block する

ここで大事なのは Wait 関数がゴルーチンの終了を待つわけではない」 ということです(気になるかたは原文の英語を読んでもらえると良いと思います)。

さらに 1 の仕様もポイントです 💡

Wait は自身が呼ばれたゴルーチン以外のすべてのゴルーチンが durably block になるまで、自身が呼ばれたゴルーチンをブロックする、ということです。
また言い換えると 「自身以外のすべてのゴルーチンが durably block になったとき、自身のゴルーチンを unblock する」 と解釈できます。

Wait 関数の挙動

処理の流れを明確にするため、簡易的な図を用意してみました。先ほどの図と同じように

  • 左がテストを実行するメインのゴルーチン
  • 右が Set を呼び出したときに生成されるゴルーチン(サブのゴルーチン)

を表しています。

メインとサブのゴルーチンの両方がブロックされたのちに時刻が進む、というところまでは、先ほどと同じ流れになります。それ以降は以下のように処理が進みます。

  1. Wait が呼ばれたとき、サブのゴルーチンは実行中なら 2、終了したなら 4 へ
  2. サブのゴルーチンが durably block になるまで待つ
  3. durably block になることなく、サブのゴルーチンが終了
  4. Wait はサブのゴルーチンを気にする必要がなくなって unblock
  5. メインのゴルーチンの処理が進む

今回のケースでは 「サブのゴルーチンが durably block になるのを待っていると、durably block になることなくサブのゴルーチンが終了してしまった」 ということになります。

このような挙動が「ゴルーチンの終了を待っているような挙動に見える」わけですね!

Go クイズ!

Run 関数と Wait 関数の挙動がざっくりと分かったところで、2 問ほど Go クイズを考えてみました!

答えを見る前に皆さんも考えてもらえると、より理解が深まると思います 🙋

Q1. テストで Set だけ呼んだとき、下記で出力される時刻はどうなるか?

  • 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() の出力はどうなるでしょうか?

  1. テストを実行したときの現在時刻
  2. 2000-01-01 00:00:00 (UTC)
  3. 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です!🎉

こちらも簡易的な図を使って、実際の挙動を解説してみます。

  1. Set からサブのゴルーチンが作成される
  2. メインのゴルーチンがブロックされる
    • Run は bubble の中のすべてのゴルーチンが終了するまで待つ」という Run の仕様があるため
  3. サブのゴルーチンが time.Sleep でブロックされる
  4. メインとサブのゴルーチンがブロックされた状態になるので、時刻が進む
  5. fmt.Println(time.Now()) が実行される
  6. サブのゴルーチンが終了する
  7. メインのゴルーチンが終了する

ここでポイントになるのが Run は bubble の中のすべてのゴルーチンが終了するまで待つ」 という Run の仕様です 💡

この仕様があるため、Run はその中身の実行が終わっても、即時にテストを終了することはありません。Run の中で実行されている すべてのゴルーチンの終了を待ったのちに、テストを終了する ような挙動になります。

Q2. テストのメインのゴルーチンの time.Sleep を外すとどうなるか?

さて、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 回実行したときの結果はどうなるでしょうか?

  1. 100 回すべて失敗する(テストが落ちる)
  2. 何回かしか成功しない(flaky なテストになる)
  3. 100 回すべて成功する(テストは落ちない)

Wait はゴルーチンの終了を待つわけではない、ということを思い出していただくと良いかもしれません。


実際に確かめてみます!

GOEXPERIMENT=synctest go test -run "TestCache_Get" -count=100 | grep 'FAIL:' | wc -l
     100

正解は 1 です!🎉

簡易的な図を使って説明してみます(今回の図はあまり役立たないかもしれません)。

  1. Set からサブのゴルーチンが作成される
  2. Get が呼ばれる
  3. Wait が呼ばれる
  4. サブのゴルーチンが time.Sleep によって durably block される
  5. Wait が解除される
  6. Get が呼ばれる
  7. メインのゴルーチンがサブのゴルーチンの処理待ちによりブロックされる
  8. メインとサブのゴルーチンの 2 つすべてのゴルーチンがブロックされ、時刻が進む
  9. c.v = zero が実行され、サブのゴルーチンが終了
  10. サブのゴルーチンの終了を待って、メインのゴルーチンが終了

となります。

3, 4 はどちらが先に実行されるかは不明ですが、重要なのは 「サブのゴルーチンが durably block になった状態であれば、Wait はその時点で unblock する」 ということです。

これは Wait の仕様である「Wait は、現在の bubble 内の現在のゴルーチン以外のすべてのゴルーチンが持続的にブロック (durably block) されるまでブロックする」を言い換えたものになります!


また、サブのゴルーチンの time.Sleep によって時刻が進むのは 「すべてのゴルーチンがブロックされたとき = メインのゴルーチンがサブのゴルーチンの終了待ちになったとき」 になります。

これらの解釈には以下の RunWait の仕様が関係しています。

  • 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 を使って書いてみたいと思っています!

以上、ここまでお読みいただきありがとうございました!!

GitHubで編集を提案
2
Canary Tech Blog

Discussion

ログインするとコメントできます