synctestを用いたGoの並行コードのテスト

並行Goテストでスリープ処理に頼るのをやめましょう

目次

Goの並行コードのテストには、これまで少しの規律が必要でした。 ゴルーチンは軽量で、チャネルはシンプル、コンテキストのキャンセルは慣用的です——バックグラウンドワーカーとタイマーは、実世界のGoサービスにどこにでもあります。

しかし、それらをすべて信頼性高くテストすることは、それを書くことより難しいのです。

Testing concurrent Go code with synctest

よくある悪いパターンは以下のようなものです:

go doSomething()

time.Sleep(100 * time.Millisecond)

if !done {
	t.Fatal("background work did not finish")
}

このテストは、あなたのラップトップではパスしてもCIでは失敗するかもしれません。あるいは6ヶ月間パスしていたものが、負荷のかかったランナーで突然失敗するかもしれません。あるいは、誰かが「安全のために」とスリープを100ミリ秒から2秒に増やしたために、遅くなってしまうかもしれません。

これは良いテストではありません——タイマーを使ったギャンブルであり、テストスイートが大きくなるにつれてそのギャンブルのコストは高くなります。

testing/synctestパッケージは、Go開発者に非同期や時間依存のコードをテストするより良い方法を提供します。テストを孤立したバブル内で実行させ、そのバブルに偽のクロックを提供し、バブル内のゴルーチンがブロックされるまで待つ方法を提供します。

その結果はシンプルですが力強く:

  • 任意のスリープなし
  • より高速なタイムアウトテスト
  • より決定論的な並行テスト
  • コンテキストキャンセルのより良いテスト
  • バックグラウンドゴルーチンのより良いテスト
  • CIの不安定さ(flakiness)の減少

少し意見的なバージョン:もしあなたの並行Goテストが本物のtime.Sleepに依存しているなら、そのテストは疑わしいものとして扱うべきです。

testing/synctestとは

testing/synctestは、並行コードをテストするためのGo標準ライブラリパッケージです。

2つの主要な関数を提供します:

package synctest

func Test(t *testing.T, f func(*testing.T))
func Wait()

synctest.Testは、関数を孤立したテストバブル内で実行します。そのバブル内で開始されたゴルーチンはすべてバブルの一部となり、バブル内の時間は偽のものであり、timeパッケージは本物のウォールクロックではなくその偽のクロックに対して動作します。

synctest.Waitは、バブル内の他のすべてのゴルーチンが永続的にブロックされるまで待機します。これは抽象的に聞こえますが、実用的な効果は理解しやすいです:

synctest.Test(t, func(t *testing.T) {
	time.Sleep(10 * time.Second)
})

これはテストが10秒間待機するわけではありません。synctestバブル内では、バブルがブロックされ時間の進捗を待っているときに、時間は瞬時に進みます——これがパッケージの中核となるトリックです。

なぜ並行Goテストは不安定なのか

Goのテスト全般を初めて学ぶ方のために、Go Unit Testing: Structure & Best Practicesでは、この記事の基盤となるテストパッケージ、テーブル駆動テスト、モッキングパターンをカバーしています。並行テストが不安定になる理由は、通常以下の3つのいずれかです。

第一に、スケジューラーに依存しているためです。ゴルーチンはあなたのマシンではすぐに実行されますが、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つのゴルーチンでdoneに書き込み、同期なしで別のゴルーチンから読み取っています。

この特定の例はチャネルやsync.WaitGroupで修正できますし、多くの場合そうすべきです。しかし、テスト対象のコードがタイマー、コンテキストデッドライン、time.AfterFunc、バックグラウンドワーカー、または遅延クリーンアップを使用する場合、テストは依然として不格好になる可能性があります——そして、まさにそこがtesting/synctestが役立つところです。

核心となるアイデア:バブル内でテストを実行する

synctestバブルは、その内部で作成されたゴルーチンを孤立させます。

以下のように使用します:

func TestSomethingConcurrent(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		// ここで並行コードをテストします。
	})
}

バブル内では:

  • テストによって開始されたゴルーチンはバブルに属します。
  • タイマーとスリープは偽のクロックを使用します。
  • synctest.Waitはバックグラウンドアクティビティが落ち着くまで待機できます。
  • テストは外部のゴルーチン、本物のネットワークI/O、または外部プロセスへの依存を避けるべきです。

バブルは魔法ではありません。悪い並行設計を良いものにはしません。しかし、時間とブロック動作がより決定論的な制御された環境をテストに提供します。

テストにおけるtime.Sleepの問題

テストにおける本物のtime.Sleepは、通常以下の2つのいずれかを意味します:

私が実際に気にしているイベントを待つ方法を知りません。

または:

私が気にしていることは知っていますが、テスト対象のコードはそれを観測するクリーンな方法を提供していません。

どちらも真剣に受け止めるべき設計のシグナルです——それらは、本番コードがよりクリーンな観測可能性や、より明確な協調メカニズムから利益を得られる可能性のある箇所を指しています。

バックグラウンドで作業を完了する関数を考えてみましょう:

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の最も良い用途の一つは、context.Contextのデッドラインとタイムアウトをテストすることです。context.Canceledcontext.DeadlineExceededをサービスとハンドラレイヤーを通して正しく伝播させることは、Go Error Handling Architecture: Boundaries and Patternsで詳しくカバーされています——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が快適にするテストの一例です。

現実的なタイムアウト値をコードに保ちつつ、テストを高速に実行できます。

コンテキストキャンセルのテスト

バックグラウンドゴルーチンと競走せずに、明示的なキャンセルをテストすることもできます。

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です。

それは、テストが結果をチェックする前に、バックグラウンドゴルーチンにキャンセルを観測し落ち着く機会を与えます。

synctest.Waitが何を行うか

synctest.Waitは、バブル内の他のすべてのゴルーチンが永続的にブロックされるまで待機します。

通常の言葉で言えば、以下を意味します:

このテスト内のゴルーチンが安定したブロックポイントに到達するまで待機します。

これは、テストがゴルーチンを開始し、そのゴルーチンが終了したか待機中であることを知る必要がある場合に役立ちます。

例えば:

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パッケージは偽のクロックを使用します。

偽のクロックは固定された時間で開始されます。バブル内のすべてのゴルーチンが永続的にブロックされ、何かをアンブロックするために時間を進める必要がある場合にのみ進みます。

つまり、このテストは高速です:

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())
		}
	})
}

この例には意図的な設計の詳細があります:ワーカーにシャットダウンパスがあります。

それはテストのためだけでなく、本番環境によいことです。

テストは 종종あなたのゴルーチンが実際に停止できるかどうかを明らかにします。

synctestとゴルーチンリーク

testing/synctestはここで役立ちます。synctest.Testはバブル内のゴルーチンが終了するまで待機するため、リークしたゴルーチンを無視するのは難しくなります。バックグラウンドゴルーチンが決して終了しない場合、テストは失敗し、 silently workを残しません——これは良いことです。

並行コードには明確な所有権が必要です。関数がゴルーチンを開始する場合、それを停止するための明示的な方法、または永遠に生き続けることを許容する文書化された理由が必要です。テストでは、「永遠」はほとんど受け入れられません。

良いパターンは以下です:

ctx, cancel := context.WithCancel(t.Context())
defer cancel()

そして、コンテキストがキャンセルされたときにゴルーチンを停止させます。

実践における「永続的にブロック」とは

公式ドキュメントは「永続的にブロック」という用語を使用します。

すべてのランタイム詳細を暗記する必要はありませんが、実用的な意味を理解すべきです。

ゴルーチンは、同じsynctestバブル内の何かによってのみアンブロックされる方法でブロックされている場合、永続的にブロックされています。

例には以下が含まれます:

  • バブル内で作成されたチャネルからの受信
  • バブル内で作成されたチャネルへの送信
  • バブルに関連付けられたsync.WaitGroupの待機
  • time.Sleepでのスリープ
  • 特定のタイマー操作の待待

バブル外の何かによってアンブロックされる可能性があるため、永続的にブロックされていないものもあります。

例には以下が含まれます:

  • ネットワークI/O
  • システムコール
  • 外部プロセス操作
  • 一部のミュテックス待機
  • バブル外のゴルーチンとの相互作用

これが、synctestテストが自己完結的で、バブルが観測できない外部同期から自由であるべきである理由です。本物のネットワークと話す統合テストのラッパーとしてsynctestを使用しないでください。

synctestが得意なこと

testing/synctestは、非同期動作围绕するユニットテストに特に優れています。

良い候補には以下が含まれます:

  • コンテキストキャンセル
  • コンテキストタイムアウト
  • リトライループ
  • バックオフロジック
  • 遅延クリーンアップ
  • タイマー駆動ワーカー
  • ティッカー駆動ループ
  • バックグラウンドゴルーチン
  • タイムアウト動作
  • チャネル協調
  • time.AfterFunc
  • ゴルーチンの決定論的な待機

最高の使用ケースは、難しい部分が時間やスケジューリングであり、外部I/Oではないコードです。

synctestが得意でないこと

testing/synctestはすべての並行テストの代替ではありません。

それはあらゆる可能なレースのための完全な決定論的スケジューラーではありません。

それはレースディテクターの代替ではありません。

それは統合テストの代替ではありません。

それは本物のネットワークI/Oを決定論的にしません。

それは悪いゴルーチンライフサイクル設計を修正しません。

それはチャネル、コンテキスト、所有権、シャットダウンを無視してよいという意味ではありません。

正しいレイヤーでsynctestを使用します:並行および時間依存動作のための決定論的ユニットテスト。

他のレイヤーには他のツールを使用します:

  • go test -raceを使用してデータレースを検出
  • 本物の依存関係には統合テストを使用
  • スループットと競合にはロードテストを使用
  • パフォーマンスにはベンチマークを使用
  • 本番環境の動作にはトレースとプロファイリングを使用

synctestとレースディテクター

testing/synctestとレースディテクターは異なる問題を解決します。

レースディテクターは安全でない並行メモリアクセスを見つけます。

synctestはテストにおける非同期タイミングと待機を制御するのを助けます。

両方を頻繁に使用するべきです。

例えば、適切な同期がない場合、これはsynctestバブル内でも依然としてレースです:

value := 0

go func() {
	value = 1
}()

_ = value

synctest.Waitは一部のテストパターンに同期ポイントを提供できますが、コード内のすべての並行アクセスが自動的に安全であるという意味ではありません。

以下で並行テストを実行します:

go test -race ./...

レースディテクターは依然としてGoが提供する最も良いツールの一つです。それをGo Linters: Essential Tools for Code Qualityと組み合わせることで、あらゆる並行コードベースに対して堅固な静的解析とランタイムチェックのベースラインを得ることができます。

synctestと手動偽クロック

testing/synctestの前に、多くのチームは手動偽クロックを使用していました。

それは依然として良い設計であり得ます。

手動クロックインターフェースは以下のように見えるかもしれません:

type Clock interface {
	Now() time.Time
	After(time.Duration) <-chan time.Time
	Sleep(time.Duration)
}

そして本番コードは本物のクロックを使用し、テストは偽のクロックを使用します。

これは明示的な制御を提供しますが、コストがあります:

  • より多くのインターフェース
  • より多くの配管
  • より多くのテスト専用抽象化
  • コードが偽のクロックを偶然に迂回するより多くの方法

synctestは魅力的です。timeパッケージを使用する通常のコードがテストバブル内で偽の時間に対して実行できるため。

それは多くの場合、クロック注入の必要性を減らします。

私の意見:synctestが本番コードをシンプルに保つ場合に使用します。クロック制御がドメイン設計の一部である場合、またはsynctestが提供するものを超える制御が必要な場合にのみ、注入されたクロックを使用します。Goでの依存性注入パターンのより広い見通し——テスト可能な抽象化をいつどのように注入するかを含む——については、Dependency Injection in Go: Patterns & Best Practicesを参照してください。

synctestとチャネル、WaitGroups

良い同期をsynctestで置き換えないでください。

あなたのコードが完了チャネル、コールバック、またはWaitメソッドを公開できる場合、それは spesso良い設計です。

例えば:

type Server struct {
	done chan struct{}
}

func (s *Server) Done() <-chan struct{} {
	return s.done
}

テストはそれを直接待機できます。

synctestは、テスト対象の動作が時間、コンテキストデッドライン、バックグラウンドスケジューリング、または非同期コールバックを含む場合に最も役立ちます。

最高のテストは 종종両方を組み合わせます:

  • 本番コードは明示的なシャットダウンまたは完了シグナルを持つ
  • synctestは本物の時間待機を除去する
  • Waitはバックグラウンドアクティビティを決定論的にする

一般的なミス

ミス1:すべてのテストをsynctestでラップする

synctestをどこでも使用しないでください。コードが同期的である場合、通常のテスト関数の方が明確で、バブルラッパーを追加することは unnecessary machineryを導入し、テストを読みやすく、理屈立てやすくすることを難しくします。

ミス2:バブル内で本物のネットワークI/Oをテストする

synctestテストを自己完結的に保ってください。テストが本物のネットワークソケット、外部サービス、データベース、またはサブプロセスを使用する場合、それはsynctestバブル内ではなく統合テストに属します。ユニットテストにはフェイクを使用し、本物の依存関係はバブルの隔離が適用されない分離された統合テストのために予約します。

ミス3:ゴルーチンをリークする

テストがゴルーチンを開始する場合、明確な出口パスがあることを確認してください。コンテキストキャンセル、クローズされたチャネル、または明示的な停止メソッドを使用——決して停止しないゴルーチンは、本番環境の臭いであり、synctestが隠すのではなく表面化するテストの臭いです。

ミス4:パッケージレベルの状態に依存する

パッケージレベルのチャネル、タイマー、WaitGroupsは、微妙な方法でバブルの隔離を壊す可能性があります。すべてのテスト状態をsynctest.Test関数内で作成することを優先し、すべてのリソースがバブルに属し、そのライフタイムがテストに明確にスコープされるようにします。

ミス5:偽の時間を実際の時間として扱う

偽の時間は決定論的テストのためにあり、パフォーマンス測定のためではありません。1時間を瞬時に進めるテストは、CPUコスト、ロック競合、メモリ使用量、または本番環境の本物のスケジューリング動作について有用な何も教えてくれません——それらの質問にはベンチマークとロードテストを使用してください。

ミス6:レースディテクターを無視する

synctestgo test -raceの代替ではなく、2つのツールは異なる問題を解決します。バブルだけでは検出できない安全でない並行メモリアクセスをキャッチするために、synctestテストと一緒にレースディテクターを実行してください。

実践的なチェックリスト

testing/synctestでテストを書く際にこのチェックリストを使用します。

synctestを使用するべきとき

  • コードがゴルーチンを開始する場合
  • コードがtime.Sleepを使用する場合
  • コードがタイマーやティッカーを使用する場合
  • コードがコンテキストデッドラインを使用する場合
  • コードにリトライやバックオフ動作がある場合
  • テストが現在任意のスリープを使用している場合
  • テストがCIで不安定である場合
  • テストが本物の時間を待つために遅い場合

synctestを避けるべきとき

  • コードが同期的である場合
  • テストが本物のネットワークI/Oに依存する場合
  • テストが外部プロセスに依存する場合
  • テストが実際に統合テストである場合
  • パフォーマンスを測定しようとしている場合
  • コードにクリーンなシャットダウンパスがない場合

このパターンを優先する

func TestSomething(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		// 準備。
		ctx, cancel := context.WithCancel(t.Context())
		defer cancel()

		// 実行。
		_ = ctx

		// バックグラウンド作業が落ち着くまで待つ。
		synctest.Wait()

		// 必要に応じて偽の時間を進める。
		time.Sleep(1 * time.Second)
		synctest.Wait()

		// 検証。
	})
}

このパターンはシンプルです:

  • バブル内でセットアップ
  • バブル内で作業を開始
  • バックグラウンドアクティビティが落ち着くまで待つ
  • 必要に応じで偽の時間を進める
  • 同期後に検証

実際のプロジェクトでtesting/synctestを使用する場所

探すべき最高の場所は、 spessoシンプルなビジネスロジックではありません。

これらの臭いを持つテストを探します:

grep -R "time.Sleep" .
grep -R "time.After" .
grep -R "WithTimeout" .
grep -R "WithDeadline" .
grep -R "NewTicker" .
grep -R "AfterFunc" .

そして質問します:

  • このテストは本物の時間を待つために遅いですか?
  • このテストはゴルーチンがすでに実行されると仮定するために不安定ですか?
  • このテストはネットワークと外部プロセスから孤立できますか?
  • バックグラウンドゴルーチンはクリーンに停止できますか?
  • 偽の時間はアサーションをより明確にしますか?

良い候補は spesso以下の場所に住んでいます:

  • ワーカーパッケージ
  • リトライパッケージ
  • キャッシュパッケージ
  • スケジューラーパッケージ
  • キューコンシューマー
  • HTTPクライアントラッパー
  • タイムアウトミドルウェア
  • バックグラウンドクリーンアップコード
  • レート制限コード

1つの不安定なテストから始めます。一度に全体のコードベースを移行しないでください。テストスイートが非同期コードと一緒に並行テーブル駆動テストを使用する場合、Parallel Table-Driven Tests in 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、偽時間スリープ、または明示的な同期に置き換えます。変換は通常小さく——最も難しい部分はゴルーチンにクリーンなシャットダウンパスがあることを確認することです。

ステップ4:レースディテクターで実行する

変換後、常にgo test -race ./...で実行します。パスしたsynctestテストはコードがレースフリーであることを意味しません——それは非同期タイミングが現在決定論的であることをのみ意味します。

ステップ5:ゴルーチンライフサイクルをレビューする

テストによって開始されたすべてのゴルーチンがバブルが閉じる前に出口する方法を持っていることを確認します。もし持っていない場合、synctest.Testはリークを無視するのではなく表面化します。

ステップ6:明確性が改善される場所のみ繰り返す

ファッションのためにテストを変換しないでください。良いsynctestテストは、それが置き換えたバージョンよりも測定可能に高速で、読みやすく、または不安定でないはずです——もしそうでない場合、変換は価値がありませんでした。

私の意見的なルール

これらを実践的な経験則として使用します。

ルール1:並行ユニットテストに任意のスリープを使用しない

ゴルーチンがもしかしたら終了するのを待つスリープは臭いです。チャネル、WaitGroups、コールバック、synctest.Wait、または偽の時間で置き換えてください——十分な時間が経過したことを願うのではなく、条件を待つ任何东西。

ルール2:synctestテストを自己完結的に保つ

バブル内でゴルーチン、チャネル、コンテキスト、タイマー、ワーカーを作成します。テスト間でリークし、synctestを有用にする隔離を壊す可能性があるパッケージレベルの共有状態を避けてください。

ルール3:synctestを統合テストラッパーとして使用しない

テストが本物のデータベース、本物のネットワーク、または外部プロセスと話す場合、非常に特定の理由がない限り、それをsynctest外に保ってください。

ルール4:動作をテストし、スケジューラーの運をテストしない

目標はゴルーチンを強制的に実行することではありません。目標は、システムが意味のある状態に到達した後に観測可能な動作を検証することであり、synctest.Waitはタイミングの仮定に依存せずにそれを可能にします。

ルール5:キャンセルパスを明示的に保つ

すべてのバックグラウンドゴルーチンはシャットダウンパスを持つべきであり、テストはコンテキストをキャンセルするかチャネルをクローズし、その後ゴルーチンがクリーンに終了することを検証することで、そのパスが機能することを証明すべきです。

最終的な思考

testing/synctestは、小さく見えますがテストのクラスを書く方法を変えるGo機能の一つです。それは良い並行設計、レースディテクター、または統合テストの必要性を代替しません——しかし、それは多くの非同期ユニットテストを高速で、クリーンで、タイミングの運への依存を遥かに少なくします。

それは重要です——なぜなら並行コードはすでに十分難しいからです。テストは不確実性を減らすべきであり、追加すべきではありません。統合、コード構造、データアクセスにわたる本番環境Goパターンのより広い見通しについては、App Architecture in Productionを参照してください。

実用的な結論はシンプルです:

ゴルーチン、タイマー、タイムアウト、リトライ、キャンセル围绕する決定論的ユニットテストにsynctestを使用します。
非常に良い理由がない限り、並行テストから本物のスリープを外します。

その1つの習慣は、多くのGoテストスイートを高速で、不安定でなくします。


現在重要な事実:testing/synctestはGo 1.25で一般利用可能になり、synctest.Testsynctest.Waitを公開し、テストを孤立したバブル内で実行し、そのバブル内の時間はゴルーチンが永続的にブロックされる場合にのみ進む偽のクロックを使用します。

出典

購読する

システム、インフラ、AIエンジニアリングの新記事をお届けします。