はじめに
こんにちは。CSIGの棚井です。
Go 1.24 Release Notes の内容を紹介する「フューチャー技術ブログ Go 1.24 リリース連載」の記事です。
testing/synctest」パッケージを取り上げます。
TL;DR
- 1.24 に experimental として追加された
testing/synctest
の嬉しいポイントは2つ- いずれも「テストコードを綺麗にする」用途に使える
- 1つ目
- これまでのテストでは「実際の時間経過」を待つ必要があった
- 高スペックのPCではテストに通過しても、低スペックのCI環境では処理速度の影響で失敗するなど「Flaky Test(不安定なテスト)」に悩まされることがある
- 一定時間が経過したら、
time.AfterFunc
やtime.Timer
を用いて「一定時間が経過した後に呼び出したい処理」を追加するという対処策もある
testing/synctest
を使えば、テスト処理内での時間操作ができるsynctest.Run
内でfake clock
を使う
- これまでのテストでは「実際の時間経過」を待つ必要があった
- 2つ目
- 複数の goroutine すべての完了を待つには、WaitGroup の Add(), Done(), Wait() を利用していた
testing/synctest
を使えば、テスト処理内での goroutine の処理待ちができるsynctest.Run
の中では、synctest.Wait
により「すべての goroutine が idle 状態になったこと」を判定できる
Discussion #67434
testing/synctest
が追加されるまでの経緯は、proposal: testing/synctest: new package for testing concurrent code #67434 で会話されています。Discussion 全体のサマリーは #AI-generated issue overview
として Original Post Summary に整理されています。
Discussion では、テスト時に「実際の時間経過」が求められるようなテストケースに対して、
- テスト専用のロジックを追加せず
- より短いテスト時間で
- Flaky Test(不安定なテスト)にならない
というニーズを、いかに実現するかに焦点が当てられています。
「実際の時間経過」が求められるテストケースの解像度を上げるために、Discussion の冒頭(及び、コメント内容)で例示されている「有効期限付きキャッシュのテスト」を見ていきます。
type Cache[K comparable, V any] struct{} |
func TestCacheEntryExpires(t *testing.T) { |
このような「期限付きのキャッシュを作成する関数」に対して、「指定の時間を超過したら、キャッシュが Expire されること」をテストするためには、テスト関数の TestCacheEntryExpires
に複数の time.Sleep
が記載されているように「実際の時間経過を待つ」処理が必要になります。
上記テスト関数内では、計4秒(1秒 + 3秒)のスリープ時間が発生します。ほんの数秒であれば気に留めるまでもありませんが、現実のキャッシュ運用として数分単位や数時間単位での「時間経過後の処理」をテストしたい場合には、テストコード内での様々な工夫(もしくは単体テストは諦めてE2E テストに任せる)を入れることになります。また、これらの「工夫」は、テストコードに「テスト専用のロジック」を追加することにも繋がりがちです。
これらの問題に対して、「テスト専用のロジックを追加せず」かつ「より短いテスト時間で」かつ「Flaky Test(不安定なテスト)にならない」を同時に達成するために testing/synctest
が提案されました。こちらも Discussion 冒頭のコードを引用して以下に添付します。
func TestCacheEntryExpires(t *testing.T) { |
こちらのテスト関数内では、synctest.Run
と synctest.Wait
の2つの関数が呼ばれていること、さらに重要な点として「元のテストコードの構造は変わっていないこと」が分かります。ただし、このテストコードだけでは「具体的に何が嬉しいのか?」が分かりにくいため、ここからは release-branch.go1.24 のテストコードを動かしながら、testing/synctest
の活用に向けて解像度を上げていきます。
注意点として、testing/synctest
は experimental のため、Release Noteに
The package API is subject to change in future releases.
と記載されている通り、将来のバージョンでは取り下げられる可能性があります。
testing/synctest
- 環境構築
- 動作仕様の確認
環境構築
Goリリースノートから技術ブログを書く流れ基礎 環境を構築する の内容を踏襲して、今回の go1.24(go1.24rc2
)を動かす環境を用意します。go1.24rc2 は「こちら」にインストールコマンドがありますので、早速実行していきます。
$ go install golang.org/dl/go1.24rc2@latest |
また、Release Note の New experimental testing/synctest package には以下記述がありますので、環境変数への追加も忘れずに行います。
The synctest package is experimental and must be enabled by setting GOEXPERIMENT=synctest at build time.
$ echo $GOEXPERIMENT |
go version
と GOEXPERIMENT=synctest
の設定まで確認できれば、testing/synctest
の準備完了です。以下から、具体的なテストコードを見ていきます。
動作仕様の確認
- 例1: Example_contextWithTimeout
- 例2: TestNow
- 例3: TestWait
- 例4: TestTimerReset
例1: Example_contextWithTimeout
まずは testing/synctest
を使わないコードを見ていきます。以下の Example_contextWithTimeoutNotSynctest
を実行した場合、実行完了までにどのくらいの時間がかかるのかを、一旦手を止めてイメージしてみてください。
func Example_contextWithTimeoutNotSynctest() { |
イメージできましたか?
time.Sleep
の総時間を計算すれば、5秒 +α 程度になることはコード上から明確です。実際に実行してみると 5.189s でした。
$ go1.24rc2 test -v -run Example_contextWithTimeoutNotSynctest |
それでは、testing/synctest
を用いたバージョンを見ていきます。
先ほどの関数 Example_contextWithTimeoutNotSynctest
に対して、以下の Example_contextWithTimeout
では
- synctest.Run
- synctest.Wait
の2つを追加しています。
この関数の場合でも、実行したらどの程度の時間がかかるのかをイメージしてみてください。
func Example_contextWithTimeout() { |
testing/synctest
の導入検討経緯として「より短いテスト時間で」というのがありますので、このテストコードがもし「5秒未満」で終了するならば、本パッケージの狙いは達成されたことになります。
それでは、実際にどのくらい早くなったのかを確認してみた結果が以下です。
$ go1.24rc2 test -v -run Example_contextWithTimeout |
なんと、5.189s から 0.189s にまでテスト時間を短縮できました。
5.189s / 0.189s ≒ 27.5
このテストケースでは、testing/synctest
を用いることで、27.5倍のスピードでテストが終了しました。圧倒的な速度改善です。
例2: TestNow
続いて、fake clock
の仕様と synctest.Run
の中(Bubble
と呼ばれる)での時間進捗を見ていきます。説明の都合上、オリジナルのテストコードに対して // Point①, ②, ③
のコメントを追加しています。
func TestNow(t *testing.T) { |
もちろんテスト処理は、1秒未満で終了します。
% go1.24rc2 test -v -run TestNow |
TestNow
関数の中では、testing/synctest
での重要な仕様が2つ利用されています。
まず1つ目は、Point①
に記載された 2000-1-1 00:00:00 という時刻についてです。synctest.Run
内での fake clock
は、このテストが通過することから分かるように「UTC の 2000-1-1 00:00:00」で初期化されます。synctest 内でのテスト開始時刻は「2000年1月1日の0時」という点は、パッケージ導入初期においてはテスト内コメントなどで周知する必要がありそうです。
2つ目は Point②, ③
の 実行順序 です。事前に testing/synctest
の仕様を知らない状況で、TestNow
を読んだ場合には、Point②
の時点で goroutine 内での処理が始まることにより、goroutine 内のテストと Point③
のどちらが先に実行されるかで、Flaky Test(不安定なテスト) になることが想像できます。
それでは、本テストを連続で100回実行してみます。
$ seq 100 | xargs -I {} go1.24rc2 test -run TestNow | grep PASS | wc -l |
Flaky Test の可能性があれば、100実行すれば数回は失敗する想定ですが、すべて成功(PASS)したことを確認できました。ここが重要なポイントで、fake clock
内での時間進捗は、Bubble(synctest.Run内のこと)内の goroutine がすべて idle 状態になったとき に起こる点です。先ほどのコードでは Point③
に到達した時点にて、 Point②
で開始された goroutine が idle 状態になるまで処理待ちとなり、その後に time.Sleep(1 * time.Second)
が実行される、つまり、② -> ③ の実行順序維持されるため、Flaky Test を回避できます。
「idle 状態」については、Testing Concurrent Code Using the Experimental ‘testing/synctest’ Package に端的な要約がありますのでご参照ください。
例3: TestWait
「例2: TestNow」では、「fake clock
の時間進捗操作は、Bubble 内での goroutine がすべて idle 状態になるまで待機する」ことを利用したテストコードでした。続いて、以下のコードでは、同様の状態を明示的に待機する関数として、synctest.Wait()
を利用しています。
func TestWait(t *testing.T) { |
再帰的に goroutine を呼び出し続けて、最後に呼び出された goroutine により done = true
へと更新された場合のみに通過するテストコードです。
synctest.Wait()
をコメントアウトした場合、if !done{ ... }
が先に判定されるためテストコードは失敗します。
- synctest.Wait() |
$ go1.24rc2 test -v -run TestWait |
「synctest.Run
の中で開始した goroutine に対して、synctest.Wait()
により goroutine のすべてが idle 状態になるまで待つ」という選択肢が得られると、「平行処理が関わるテストコード」のリファクタリングが進みそうだなと思いました。
TestWait
を sync.WaitGroup
で書き換えると、以下のようになります。
func TestWaitWithWaitGroup(t *testing.T) { |
例4: TestTimerReset
最後に、testing/synctest
での時間操作は「認知負荷が高いことがある」ケースを見ていきます。
テストコードから引用した以下の TestTimerReset
について、
- 各処理のタイミングで
fake clock
は何時を示すのか - 最後の時間処理で「1+2+4」を行なっているのは、どの time.Sleep に対応した操作なのか
を一読で理解するのは、中々難しいのではないでしょうか。
func TestTimerReset(t *testing.T) { |
<-tm.C
と tm.Reset
のそれぞれの実行時点で、fake clock
は何時になっているのか(スタート時刻からどれだけ進んでいるのか)を、脳内で補完しながら読み進めるのが辛くなったため、デバッグモードにより値を見ながらで理解できました。プリントデバッグ文を追加すると以下のようになります。
func TestTimerReset(t *testing.T) { |
$ go1.24rc2 test -v -run TestTimerReset |
testing/synctest
により「テスト時間を操作する」のは非常に便利な機能ですが、その一方で、時間を任意に進めるという操作自体によりコード理解の負荷が高まる可能性も感じました。
おわりに
本記事では「testing/synctest」パッケージを取り上げました。
Disscusion の中では、標準パッケージの net/http
に含まれる以下のテスト関数について、testing/synctest の利用により「This test now executes instantaneously(現在は瞬時に実行されます)」と記載されています。
TestServerShutdownStateNew
TestTransportExpect100Continue
いずれは experimental が外れて、テスト時間の短縮になるのが楽しみな機能だなと思いました。