synctest を用いた Go の並行コードのテスト
Goの並行テストで「睡眠」に頼るのをやめよう。
並行処理を行うGoコードのテストには、常に一定の規律が求められてきました。 Goroutineは安価であり、チャンネルは単純、そしてコンテキストのキャンセルは慣用的なものです — 実際のGoサービスでは、バックグラウンドワーカーやタイマーが至る所に存在しています。
しかし、それらを確実にテストすることは、実装することよりも困難です。

一般的な悪いパターンは、次のように見慣れたものです:
go doSomething()
time.Sleep(100 * time.Millisecond)
if !done {
t.Fatal("background work did not finish")
}
このテストは、あなたのラップトップでは通るかもしれないが、CIで失敗するかもしれません。あるいは、6ヶ月間は通っていたのに、負荷のかかったランナーで突然失敗するかもしれません。あるいは、誰かが「安全策として」と言ってスリープ時間を100ミリ秒から2秒に増やしたために、テストが遅くなるかもしれません。
これは良いテストではありません — タイマーを使って賭けをしているにすぎず、テストスイートが大きくなるにつれて、その賭けはより高価なものになっていきます。
testing/synctest パッケージは、Go開発者に、非同期および時間依存型のコードの多くの形式をテストするためのより良い方法を提供します。テストを隔離されたバブル(気泡)内で実行させ、そのバブルに偽のクロックを与え、バブル内のGoroutineがブロッキングするまで待機する方法を提供します。
その結果はシンプルですが強力です:
- 任意のスリープの排除
- タイムアウトテストの高速化
- より決定的な並行処理テスト
- コンテキストキャンセルのより良いテスト
- バックグラウンドGoroutineのより良いテスト
- CIにおける不安定(flaky)なテストの減少
やや意見的(opinionated)な言い方をすれば、並行処理のGoテストが実際の time.Sleep に依存している場合、そのテストは疑わしいものとして扱うべきです。
testing/synctest とは
testing/synctest は、並行処理コードのテストのためにGo標準ライブラリに用意されたパッケージです。
主に2つの関数を提供します:
package synctest
func Test(t *testing.T, f func(*testing.T))
func Wait()
synctest.Test は、関数を隔離されたテストバブル内で実行します。そのバブル内で開始されたGoroutineもまたバブルの一部となり、バブル内の時間は偽であり、time パッケージは実際の壁時計(wall clock)ではなく、その偽のクロックに対して動作します。
synctest.Wait は、バブル内の他のすべてのGoroutineが確実にブロッキングするまで待機します。これは抽象的に聞こえるかもしれませんが、実用的な効果は簡単に理解できます:
synctest.Test(t, func(t *testing.T) {
time.Sleep(10 * time.Second)
})
これによって、テストが実際に10秒間待たされるわけではありません。synctestバブル内では、バブルがブロッキングし、時間の進行を待っている場合、時間は即座に前進できます — これがこのパッケージの背後にある核心的なトリックです。
なぜ並行処理のGoテストは不安定(flaky)なのか
Goのテスト全般に慣れていない場合、Goユニットテスト:構造とベストプラクティス は、この記事が構築されている基礎であるテストパッケージ、テーブル駆動型テスト、モックパターンを扱っています。並行処理のテストが不安定になるのは、通常、次の3つの理由のいずれかです。
第一に、スケジューラに依存していることです。Goroutineは、あなたのマシンではすぐには実行されるかもしれないが、CIでは後に実行されるかもしれません。
第二に、実際の時間に依存していることです。50ミリ秒スリープするテストは、50ミリ秒でバックグラウンド作業が完了するのに十分な時間であると仮定しています。
第三に、状態を早すぎるときに観察していることです。バックグラウンド操作が実際に完了する前に、テストが結果を確認します。
簡単な例を見てみましょう:
func TestBackgroundWorkBad(t *testing.T) {
done := false
go func() {
done = true
}()
time.Sleep(10 * time.Millisecond)
if !done {
t.Fatal("background work did not finish")
}
}
このテストには2つの問題があります。
明らかな問題はスリープです。10ミリ秒が正しい時間量であるとは保証されていません。
あまり自明でない問題は、データレースです。テストは1つのGoroutineで done に書き込み、もう1つのGoroutineで同期なしに読み取っています。
この特定の例は、チャンネルや sync.WaitGroup を使用して修正することができ、多くの場合、そうすべきです。しかし、テスト対象のコードがタイマー、コンテキストの期限、time.AfterFunc、バックグラウンドワーカー、または遅延クリーンアップを使用している場合、テストは依然として厄介になる可能性があります — それがまさに testing/synctest が役立つ場所です。
核となるアイデア:テストをバブル内で実行する
synctestバブルは、その内部で作成されたGoroutineを隔離します。
次のように使用します:
func TestSomethingConcurrent(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// Test concurrent code here.
})
}
バブル内では:
- テストによって開始されたGoroutineはバブルに属します。
- タイマーとスリープは偽のクロックを使用します。
synctest.Waitは、バックグラウンド活動が収束するまで待機できます。- テストは、外部のGoroutine、実際のネットワークI/O、または外部プロセスへの依存を避けるべきです。
バブルは魔法ではありません。悪い並行処理設計を良くするわけではありません。しかし、時間とブロッキング動作により決定的な、管理された環境をテストに与えます。
テストにおける time.Sleep の問題
テストにおける実際の time.Sleep は、通常、次の2つのうちの1つを意味します:
実際に気にしているイベントをどのように待機すればよいかわからない。
または:
気にしていることはわかっているが、テスト対象のコードがそれを観察するためのクリーンな方法を提供していない。
どちらも真剣に受け止めるべき設計のシグナルです — それは、本番コードがよりクリーンな可観測性や、より明示的な協調メカニズムの恩恵を受ける可能性がある場所を指しています。
バックグラウンドで作業を完了する関数を考えてみましょう:
type Worker struct {
out chan string
}
func NewWorker() *Worker {
return &Worker{
out: make(chan string, 1),
}
}
func (w *Worker) Start() {
go func() {
time.Sleep(5 * time.Second)
w.out <- "done"
}()
}
func (w *Worker) Result() <-chan string {
return w.out
}
悪いテストは、次のようになるかもしれません:
func TestWorkerBad(t *testing.T) {
w := NewWorker()
w.Start()
time.Sleep(6 * time.Second)
select {
case got := <-w.Result():
if got != "done" {
t.Fatalf("got %q, want done", got)
}
default:
t.Fatal("worker did not finish")
}
}
このテストは、実際に6秒間待機します。
それは遅いです。このようなテストがたくさんある場合、スイートは苦痛なものになります。
synctest を使用したより良いテストは、偽の時間を即座に前進させることができます:
func TestWorkerWithSynctest(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
w := NewWorker()
w.Start()
time.Sleep(5 * time.Second)
synctest.Wait()
select {
case got := <-w.Result():
if got != "done" {
t.Fatalf("got %q, want done", got)
}
default:
t.Fatal("worker did not finish")
}
})
}
テストは依然としてビジネス上の事実(ワーカーは5秒後に完了すべきである)を表現していますが、それを達成するために実際に5秒間費やすわけではありません。それは、時間依存の動作をテストすることと、開発者の時間を無駄にすることの違いです。
コンテキストタイムアウトのテスト
testing/synctest の最も優れた用途の1つは、context.Context の期限とタイムアウトのテストです。コンテキストのキャンセルとタイムアウト予算をどのようにサービスレイヤー間で構成すべきかは、Goの context.Context を正しく使う で扱われており、それらのレイヤーを通じて context.Canceled と context.DeadlineExceeded を正しく伝播させることは、Goエラーハンドリングアーキテクチャ:境界とパターン で扱われています — synctestは、実際の時間が経過することなく、その動作を検証することを可能にします。
コンテキストがキャンセルされるまで待機する単純な関数を見てみましょう:
func WaitForCancel(ctx context.Context, done chan<- error) {
go func() {
<-ctx.Done()
done <- ctx.Err()
}()
}
synctest なしで、これに30秒のタイムアウト付きのテストを行う場合、テストが遅くなるか、テストのためにだけタイムアウトを変更するか、どちらかになります。
synctest を使用すれば、実際のタイムアウト期間を迅速にテストできます:
func TestWaitForCancelWithTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
done := make(chan error, 1)
WaitForCancel(ctx, done)
synctest.Wait()
select {
case err := <-done:
t.Fatalf("context canceled too early: %v", err)
default:
}
time.Sleep(30 * time.Second)
synctest.Wait()
select {
case err := <-done:
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("got %v, want %v", err, context.DeadlineExceeded)
}
default:
t.Fatal("context was not canceled")
}
})
}
これは、synctest が快適にしてくれるタイプのテストです。
コード内に現実的なタイムアウト値を維持しつつ、テストを迅速に実行できます。
コンテキストキャンセルのテスト
バックグラウンドGoroutineと競合することなく、明示的なキャンセルをテストすることもできます。
func TestWaitForCancelWithExplicitCancel(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
WaitForCancel(ctx, done)
synctest.Wait()
select {
case err := <-done:
t.Fatalf("context canceled too early: %v", err)
default:
}
cancel()
synctest.Wait()
select {
case err := <-done:
if !errors.Is(err, context.Canceled) {
t.Fatalf("got %v, want %v", err, context.Canceled)
}
default:
t.Fatal("context was not canceled")
}
})
}
重要な詳細は synctest.Wait です。
それは、バックグラウンドGoroutineが結果をチェックする前に、キャンセルを観測して収束する機会を与えます。
synctest.Wait が做什么か(何をすることか)
synctest.Wait は、バブル内の他のすべてのGoroutineが確実にブロッキングするまで待機します。
通常の意味で言うと、それは:
このテスト内のGoroutineが、安定したブロッキング点に達するまで待機する。
これは、テストがGoroutineを開始し、そのGoroutineが終了したか、待機しているかを知る必要がある場合に有用です。
例えば:
func TestWaitExample(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
done := false
go func() {
done = true
}()
synctest.Wait()
if !done {
t.Fatal("goroutine did not run")
}
})
}
これは意図的に小さいものですが、アイデアを示しています。
synctest.Wait は、より良いスリープであるわけではありません — それはバブル内の同期ポイントであり、その区別は初見では思っている以上に重要です。
スリープは次のと言います:
十分な時間が経過したことを願っています。
Wait は次のと言います:
バブルが安定したブロッキング状態に達することを望みます。
2つ目は、経過時間についての推測ではなく、観測可能な条件を記述するため、テストにははるかに優れています。
synctestバブル内の偽の時間
synctestバブル内では、time パッケージが偽のクロックを使用します。
偽のクロックは、固定された時間から開始します。バブル内のすべてのGoroutineが確実にブロッキングしており、何らかのブロッキングを解除するために時間的前進が必要な場合にのみ、それは前進します。
つまり、このテストは高速です:
func TestFakeTime(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
time.Sleep(1 * time.Hour)
elapsed := time.Since(start)
if elapsed != time.Hour {
t.Fatalf("got %v, want %v", elapsed, time.Hour)
}
})
}
それは1時間待機するように読めます。
しかし、そうではありません。
これは、次のようなもののテストに有用です:
- タイムアウト
- 期限
- リトライ
- バックオフ
- 遅延クリーンアップ
- レート制限
- タイマー
- ティッカー
- コンテキストキャンセル
しかし、重要なルールが1つあります:偽の時間は、バブル内で time パッケージを使用するコードにのみ役に立ちます。
コードが外部システム、実際のネットワークI/O、またはバブル外で計測された時間に依存している場合、synctest はそれを決定的にできません。
リトライループのテスト
リトライループは、遅くて不安定なテストの一般的な原因です。
小さなリトライヘルパーを見てみましょう:
func Retry(ctx context.Context, attempts int, delay time.Duration, fn func() error) error {
var last error
for i := 0; i < attempts; i++ {
if err := fn(); err != nil {
last = err
} else {
return nil
}
if i == attempts-1 {
break
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
return last
}
通常のテストは、スイートを高速に保つために、遅延を1ミリ秒に減らすかもしれません。
それは大げさではありませんが、テストが本番コードで使用されている実際の値をテストしていないことを意味します。
synctest を使用すれば、実際の遅延を維持できます:
func TestRetryEventuallySucceeds(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx := t.Context()
calls := 0
err := Retry(ctx, 3, 10*time.Second, func() error {
calls++
if calls < 3 {
return errors.New("temporary failure")
}
return nil
})
if err != nil {
t.Fatalf("Retry returned error: %v", err)
}
if calls != 3 {
t.Fatalf("calls = %d, want 3", calls)
}
})
}
テストは、2回の10秒間の待機を表しています。
それでも迅速に実行されます。
ここが、synctest がテストの経済性を変える場所です。遅いCIを避けるためにテストに散らばった偽の小さな期間をもう必要としません。
リトライキャンセルのテスト
リトライ遅延中のキャンセルもテストできます:
func TestRetryStopsWhenContextCanceled(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
errCh := make(chan error, 1)
go func() {
errCh <- Retry(ctx, 10, 10*time.Second, func() error {
return errors.New("temporary failure")
})
}()
synctest.Wait()
cancel()
synctest.Wait()
select {
case err := <-errCh:
if !errors.Is(err, context.Canceled) {
t.Fatalf("got %v, want %v", err, context.Canceled)
}
default:
t.Fatal("Retry did not return after cancellation")
}
})
}
このテストは、リトライループが遅延を通じてスリープするのではなく、キャンセルに反応することをチェックします。
まさに、本番環境で重要となる種類の動作です。
time.AfterFunc のテスト
time.AfterFunc も、また良い適用例です。
クリーンアップをスケジュールする関数があるものとします:
type Cache struct {
cleaned chan struct{}
}
func NewCache() *Cache {
return &Cache{
cleaned: make(chan struct{}, 1),
}
}
func (c *Cache) CleanupAfter(d time.Duration) {
time.AfterFunc(d, func() {
c.cleaned <- struct{}{}
})
}
func (c *Cache) Cleaned() <-chan struct{} {
return c.cleaned
}
テストは、偽の時間を前進させることができます:
func TestCleanupAfter(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
cache := NewCache()
cache.CleanupAfter(1 * time.Minute)
synctest.Wait()
select {
case <-cache.Cleaned():
t.Fatal("cleanup happened too early")
default:
}
time.Sleep(1 * time.Minute)
synctest.Wait()
select {
case <-cache.Cleaned():
default:
t.Fatal("cleanup did not happen")
}
})
}
このテストは、両方の面を検証します:
- クリーンアップは遅延の前の発生しないこと。
- クリーンアップは遅延の後に発生すること。
そして、実際に1分間待機しません。
ティッカーのテスト
ティッカーも偽の時間でテストできますが、注意が必要です。ティッカーはしばしば長時間実行されるループで使用され、長時間実行されるループにはクリーンなシャットダウンパスが必要です。
小さなティッカーベースのカウンターを見てみましょう:
type Counter struct {
ticks int
done chan struct{}
}
func NewCounter() *Counter {
return &Counter{
done: make(chan struct{}),
}
}
func (c *Counter) Start(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
go func() {
defer ticker.Stop()
defer close(c.done)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
c.ticks++
}
}
}()
}
func (c *Counter) Wait() {
<-c.done
}
func (c *Counter) Ticks() int {
return c.ticks
}
テストは、次のようになるかもしれません:
func TestCounterTicks(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
counter := NewCounter()
counter.Start(ctx, 10*time.Second)
time.Sleep(35 * time.Second)
synctest.Wait()
cancel()
counter.Wait()
if counter.Ticks() != 3 {
t.Fatalf("ticks = %d, want 3", counter.Ticks())
}
})
}
この例には、意図的な設計上の詳細があります:ワーカーにはシャットダウンパスがあります。
それはテストのためだけでなく、本番環境のためにも良いことです。
テストは、Goroutineが実際に停止できるかどうかを示すことが多いです。
synctest と Goroutineリーク
testing/synctest は、ここでも役に立ちます。synctest.Test はバブル内のGoroutineが終了するまで待機してから返るため、リークしたGoroutineを無視するのが困難になります。バックグラウンドGoroutineが決して終了しない場合、テストは静かに作業を残すのではなく失敗します — それは良いことです。
並行処理コードには、明確な所有権(ownership)が必要です。関数がGoroutineを開始する場合、それを停止するための明示的な方法があるべきか、あるいは永遠に生かされることを許可される理由が文書化されているべきです。テストでは、「永遠に」はほとんど許容されません。所有権と並行性の上限(concurrency ceiling)を組み合わせることは、この記事が対になって提示する設計の習慣です:Goにおける並行性の制約 は、バブルがミリ秒単位で捉えるリークパスを持つ、本番側のパターンを扱っています。
良いパターンは:
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
そして、Goroutineがコンテキストがキャンセルされたときに停止するようにします。
「確実にブロッキングされている(durably blocked)」の実践的な意味
公式ドキュメントは「durably blocked(確実にブロッキングされている)」という用語を使用しています。
ランタイムのすべての詳細を記憶する必要はありませんが、実践的な意味を理解すべきです。
Goroutineが確実にブロッキングされているとは、同じsynctestバブル内の何らかのものによってのみブロッキングを解除され得る方法でブロッキングされている状態です。
例としては:
- バブル内で作成されたチャンネルからの受信
- バブル内で作成されたチャンネルへの送信
- バブルに関連付けられた
sync.WaitGroupでの待機 time.Sleepによるスリープ- 特定のタイマー操作での待機
いくつかのものは、バブル外の何らかのものによってブロッキングを解除される可能性があるため、確実にブロッキングされていません。
例としては:
- ネットワークI/O
- システムコール
- 外部プロセス操作
- 一部のミューテックスの待機
- バブル外のGoroutineとの相互作用
これが、synctestテストが自己完結的で、バブルが見ることのできない外部の同期から解放されるべきである理由です。実際のネットワークとやり取りする統合テストをラップするためのsynctestとして使用しないでください。
synctest が得意なこと
testing/synctest は、非同期動作を巡るユニットテストに特に適しています。
適切な候補としては:
- コンテキストキャンセル
- コンテキストタイムアウト
- リトライループ
- バックオフロジック
- 遅延クリーンアップ
- タイマー駆動ワーカー
- ティッカー駆動ループ
- バックグラウンドGoroutine
- タイムアウト動作
- チャンネル協調
time.AfterFunc- Goroutineへの決定的な待機
最適なユースケースは、困難な部分が時間やスケジューリングであり、外部I/Oではないコードです。
synctest が不得意なこと
testing/synctest は、すべての並行処理テストの代わりにはなりません。
それは、あらゆる可能性のある競合に対する完全な決定的なスケジューラではありません。
それは、レース検出器(race detector)の代わりにはなりません。
それは、統合テストの代わりにはなりません。
それは、実際のネットワークI/Oを決定的にしません。
それは、悪いGoroutineライフサイクル設計を修正しません。
それは、チャンネル、コンテキスト、所有権、シャットダウンを無視してよいことを意味しません。
synctest は正しいレイヤーで使用してください:並行および時間依存の動作のための決定的なユニットテスト。
他のレイヤーには他のツールを使用してください:
- データレースを検出するために
go test -raceを使用する - 実際の依存関係のために統合テストを使用する
- スループットと競合のためにロードテストを使用する
- パフォーマンスのためにベンチマークを使用する
- 本番動作のためにトレースとプロファイリングを使用する
synctest とレース検出器
testing/synctest とレース検出器は、異なる問題を解決します。
レース検出器は、安全でない並行メモリアクセスを見つけ出します。
synctest は、テストにおける非同期タイミングと待機の制御を助けます。
両方をしばしば使用するべきです。
例えば、適切な同期がない場合、synctestバブル内でもこれは依然としてレースです:
value := 0
go func() {
value = 1
}()
_ = value
synctest.Wait は、いくつかのテストパターンで同期ポイントを提供できますが、コード内のすべての並行アクセスが自動的に安全であることを意味するわけではありません。
並行処理のテストは、次のように実行してください:
go test -race ./...
レース検出器は依然としてGoが提供する最高のツールの1つです。Goリンター:コード品質のための必須ツール と組み合わせることで、あらゆる並行処理コードベースに対して、堅牢な静的解析とランタイムチェックの基準が得られます。
synctest と手動の偽のクロック
testing/synctest の前に、多くのチームは手動の偽のクロックを使用していました。
それは依然として良い設計である可能性があります。
手動のクロックインターフェースは、次のようになるかもしれません:
type Clock interface {
Now() time.Time
After(time.Duration) <-chan time.Time
Sleep(time.Duration)
}
そして本番コードは実際のクロックを使用し、テストは偽のクロックを使用します。
これは明示的な制御を与えますが、コストがあります:
- より多くのインターフェース
- より多くの配管(plumbing)
- より多くのテスト専用の抽象化
- コードが意図せず偽のクロックをバイパスするより多くの方法
synctest が魅力的である理由は、time パッケージを使用する通常のコードが、テストバブル内で偽の時間に対して実行できることです。
それは、多くの場合、クロック注入の必要性を減らします。
私の意見:synctest が本番コードをよりシンプルに保つ場合は、synctest を使用してください。クロック制御がドメイン設計の一部である場合、またはsynctestが提供するものの外の制御を必要とする場合にのみ、注入されたクロックを使用してください。Goの依存性注入パターンについて、より広い観点から — テスタブルな抽象化をいつどのように注入するかを含む — Goにおける依存性注入:パターンとベストプラクティス を参照してください。
synctest とチャンネルおよびWaitGroup
良い同期を synctest で置き換えないでください。
コードが完了チャンネル、コールバック、または Wait メソッドを公開できる場合、それは多くの場合、良い設計です。
例えば:
type Server struct {
done chan struct{}
}
func (s *Server) Done() <-chan struct{} {
return s.done
}
テストは、それに基づいて直接待機できます。
synctest が最も有用なのは、テスト対象の動作が時間、コンテキスト期限、バックグラウンドスケジューリング、または非同期コールバックを伴う場合です。
最良のテストは、しばしば両方を組み合います:
- 本番コードには明示的なシャットダウンまたは完了シグナルがある
- synctestは実際の時間の待機を削除する
- Waitはバックグラウンド活動を決定論的にする
よくある間違い
間違い1:すべてのテストをsynctestでラップする
synctest を至る所で使用しないでください。コードが同期である場合、単純なテスト関数の方が明確であり、バブルラッパーを追加することは、テストを読みやすく、推論しやすくするために不要な機構を導入するだけです。
間違い2:バブル内で実際のネットワークI/Oをテストする
synctestテストは自己完結的であるように保ってください。テストが実際のネットワークソケット、外部サービス、データベース、またはサブプロセスを使用する場合、それはsynctestバブル内ではなく、統合テストに属します。ユニットテストにはフェイクを使用し、実際の依存関係は、バブルの隔離が適用されない別の統合テストに留めておいてください。依存関係がHTTPハンドラーである場合、Go 1.27の httptest.NewTestServer は実際のソケットの必要性を完全に排除します — それはメモリ内フェイクネットワーク上でサーバーを実行し、それはバブルがまさに必要とするものであり、Go 1.27 アップグレードガイド に示されているように、synctestと組み合わせて決定論的なラウンドトリップタイミングを実現します。
間違い3:Goroutineリーク
テストがGoroutineを開始する場合、明確な終了パスがあることを確認してください。コンテキストキャンセル、閉じられたチャンネル、または明示的な停止メソッドを使用してください — 決して停止しないGoroutineは、本番環境でのクセ(smell)であり、synctestが隠すのではなく表面化するテストのクセでもあります。
間違い4:パッケージレベルの状態への依存
パッケージレベルのチャンネル、タイマー、WaitGroupは、バブルの隔離を微妙な方法で壊す可能性があります。すべてのリソースがバブルに属し、そのライフタイムが明確にテストにスコープされるように、すべてのテスト状態を synctest.Test 関数内で作成する方が良いです。
間違い5:偽の時間を実際の時間として扱う
偽の時間は、パフォーマンス測定ではなく、決定論的なテストのためのものです。即座に1時間前進するテストは、CPUコスト、ロック競合、メモリ使用量、または本番環境での実際のスケジューリング動作について、何の有用な情報も提供しません — それらの質問にはベンチマークとロードテストを使用してください。
間違い6:レース検出器を無視する
synctest は go test -race の代わりにはなりません、そして2つのツールは異なる問題を解決します。バブルだけでは検出できない、安全でない並行メモリアクセスを捕まえるために、synctestテストと並行してレース検出器を実行してください。
実践的なチェックリスト
testing/synctest を使用してテストを書く際に、このチェックリストを使用してください。
synctest を使用する場合
- コードがGoroutineを開始する場合
- コードが
time.Sleepを使用する場合 - コードがタイマーまたはティッカーを使用する場合
- コードがコンテキスト期限を使用する場合
- コードにリトライまたはバックオフ動作がある場合
- テストが現在、任意のスリープを使用している場合
- テストがCIで不安定(flaky)である場合
- テストが実際の時間を待機しているため遅い場合
synctest を避ける場合
- コードが同期である場合
- テストが実際のネットワークI/Oに依存する場合
- テストが外部プロセスに依存する場合
- テストが実際のところ統合テストである場合
- パフォーマンスを測定しようとしている場合
- コードにクリーンなシャットダウンパスがない場合
このパターンを優先する
func TestSomething(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// Arrange.
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
// Act.
_ = ctx
// Let background work settle.
synctest.Wait()
// Advance fake time if needed.
time.Sleep(1 * time.Second)
synctest.Wait()
// Assert.
})
}
このパターンはシンプルです:
- バブル内でセットアップする
- バブル内で作業を開始する
- バックグラウンド活動が収束するまで待機する
- 必要な場合のみ偽の時間を前進させる
- 同期後にアサーションを行う
実際のプロジェクトで testing/synctest を使う場所
最適な場所は、通常、単純なビジネスロジックではありません。
これらのクセ(smell)を持つテストを探してください:
grep -R "time.Sleep" .
grep -R "time.After" .
grep -R "WithTimeout" .
grep -R "WithDeadline" .
grep -R "NewTicker" .
grep -R "AfterFunc" .
そして、質問してください:
- このテストは、実際の時間を待機しているために遅いか?
- このテストは、Goroutineがすでに実行されたことを仮定しているために不安定(flaky)か?
- このテストは、ネットワークと外部プロセスから隔離できるか?
- バックグラウンドGoroutineをクリーンに停止できるか?
- 偽の時間がアサーションをより明確にするか?
良い候補は、しばしば次のような場所にあります:
- ワーカーパッケージ
- リトライパッケージ
- キャッシュパッケージ
- スケジューラパッケージ
- キューコンシューマー
- HTTPクライアントラッパー
- タイムアウトミドルウェア
- バックグラウンドクリーンアップコード
- レート制限コード
1つの不安定なテストから始めてください。一度にコードベース全体を移行しないでください。テストスイートが並行コードとともに並列テーブル駆動型テストを使用している場合、Goにおける並列テーブル駆動型テスト は、synctestのアプローチと自然にペアになる t.Parallel() パターンとレース条件のトラップを扱っています。
例:前後
現実的な悪いテストを見てみましょう:
func TestRetryBad(t *testing.T) {
calls := 0
err := Retry(context.Background(), 3, 500*time.Millisecond, func() error {
calls++
if calls < 3 {
return errors.New("temporary failure")
}
return nil
})
if err != nil {
t.Fatalf("Retry returned error: %v", err)
}
if calls != 3 {
t.Fatalf("calls = %d, want 3", calls)
}
}
これは、2回のリトライ遅延が発生するため、約1秒待機します。
それは悪くないように聞こえるかもしれませんが、多くのテストと複数のパッケージで掛け合わせてみてください。遅いテストは、開発者がテストをより頻繁に実行するのを妨げます。
次に、synctestバージョン:
func TestRetryWithSynctest(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
calls := 0
err := Retry(t.Context(), 3, 500*time.Millisecond, func() error {
calls++
if calls < 3 {
return errors.New("temporary failure")
}
return nil
})
if err != nil {
t.Fatalf("Retry returned error: %v", err)
}
if calls != 3 {
t.Fatalf("calls = %d, want 3", calls)
}
})
}
テストは実際の遅延値を維持し、スイートは高速のまま、意図がより明確になります。それは testing/synctest の主な価値です。
synctest を安全に導入する方法
徐々に導入することを提案します。
ステップ1:不安定または遅い並行処理テストを見つける
実際のスリープとタイムアウト集約型のテストを検索してください。前のセクションのgrepコマンドは、コードベース全体の候補を特定するための良い出発点です。
ステップ2:1つのパッケージを選ぶ
明確な非同期動作があり、実際の外部サービスを必要としないパッケージを選びます。ワーカーパッケージ、リトライヘルパー、タイマー駆動コンポーネントは、最初の対象として理想です。
ステップ3:1つのテストをコンバートする
テストを synctest.Test でラップし、任意のスリープを synctest.Wait、偽の時間スリープ、または明示的な同期に置き換えます。コンバージョンは通常小さいものですが — 最も困難な部分は、Goroutineがクリーンなシャットダウンパスを持っていることを確認することです。
ステップ4:レース検出器と実行する
コンバージョン後に常に go test -race ./... で実行してください。パスするsynctestテストは、コードがレースフリーであることを意味しません。非同期タイミングが現在決定論的であることだけを意味します。
ステップ5:Goroutineライフサイクルをレビューする
テストによって開始されたすべてのGoroutineに、バブルが閉じる前に終了する方法があることを確認してください。もしそうではなく、synctest.Test はそれを静かに無視するのではなく、リークを表面化させます。
ステップ6:明確さの向上がある場所にのみ繰り返す
ファッションのためにテストをコンバートしないでください。良いsynctestテストは、置き換えたバージョンよりも測定可能に速く、読みやすく、または不安定(flaky)度が低くなければなりません — もしそうでなければ、コンバージョンは値がありませんでした。
私の意見的なルール
これを実践的な基準として使用してください。
ルール1:並行処理のユニットテストに任意のスリープは使わない
Goroutineがもしかしたら完了するのを待機するスリープは、クセ(smell)です。チャンネル、WaitGroup、コールバック、synctest.Wait、または偽の時間 — 十分な時間が経過することを願うのではなく、条件を待つものを何でも使用して、それを置き換えてください。
ルール2:synctestテストは自己完結的に保つ
バブル内でGoroutine、チャンネル、コンテキスト、タイマー、ワーカーを作成してください。テスト間でリークし、synctestを有用にする隔離を壊し得るパッケージレベルの共有状態を避けてください。
ルール3:synctestを統合テストのラッパーとして使用しない
テストが実際のデータベース、実際のネットワーク、または外部プロセスとやり取りする場合、非常に具体的な理由がない限り、それをsynctestの外に保ってください。
ルール4:スケジューラの運ではなく動作をテストする
目標は、Goroutineを実行させるように強制することではありません。目標は、システムが意味のある状態に達した後、観測可能な動作を検証することであり、synctest.Wait はタイミングの仮定に依存することなしにそれを可能にします。
ルール5:キャンセルパスを明示的に保つ
すべてのバックグラウンドGoroutineにはシャットダウンパスがあり、テストはコンテキストをキャンセルしたりチャンネルを閉じたりして、Goroutineがクリーンに終了することを確認することで、そのパスが機能することを証明すべきです。
最終的な思考
testing/synctest は、小さく見えますが、テストを書く方法を1つのクラスに変えるGoの機能の1つです。それは、良い並行処理設計、レース検出器、または統合テストの必要性を置き換えませんが、多くの非同期ユニットテストをより速く、よりクリーンにし、タイミングの運に大きく依存しなくします。
それは重要ですが、並行処理コードはすでに十分難しいからです。テストは不確実性を減少させるべきで、それに加えるべきではありません。統合、コード構造、データアクセスにわたる本番Goパターンのより広いビューについては、本番環境でのアプリケーションアーキテクチャ を参照してください。
実践的な教訓はシンプルです:
Goroutine、タイマー、タイムアウト、リトライ、キャンセルを巡る決定論的なユニットテストのためにsynctestを使用する。
非常に良い理由がない限り、並行処理テストから実際のスリープを排除する。
その1つの習慣が、多くのGoテストスイートをより速く、より安定させます。
重要な現在的事实は:testing/synctest はGo 1.25で一般利用可能になり、synctest.Test と synctest.Wait を公開し、テストを隔離されたバブル内で実行し、そのバブル内の時間は、Goroutineが確実にブロッキングされている場合にのみ前進する偽のクロックを使用します。
情報源
- https://pkg.go.dev/testing/synctest
- https://go.dev/blog/testing-time
- https://go.dev/blog/synctest
- [https://go.dev/blog/testing-time](“Testing Time (and other asynchronicities) - The Go Programming Language”)
- https://go.dev/doc/go1.25
- https://go.dev/blog/go1.25