課題とスコープ
対象の関数は、バックグラウンドの goroutine を開始し、指数バックオフでリトライを行い、タイムアウトが発生した際に context.AfterFunc を通じて結果を記録します。time.Sleep を使用するテストは、低速でフレイキーになりやすく、バックグラウンドの処理を残してしまう可能性があります。Go の testing/synctest を使用して決定論的なテストを設計し、実験的 API と安定版 API、goroutine リーク、実際の I/O、クロック境界をカバーしてください。
API とバージョンはターゲットとなる Go リリースに一致させる必要があります。これはバックエンドコーディング、並行処理ライブラリ、およびインフラストラクチャの職種に適しています。コアとなるスキルは決定論的な並行テストであるため、coding に属します。
面接官が評価するポイント
第一に、synctest のバブルを理解しているか。これは goroutine と時間を分離し、Wait によってバブル内の処理が静止状態(quiescence)に達するようにします。
第二に、仮想時間と実時間を分離できるか。バブル内で使用される時間 API は仮想的に進めることができますが、実際のネットワーク、ファイル、または外部の goroutine の挙動が自動的に決定論的になるわけではありません。
第三に、キャンセルとクリーンアップをテストできるか。コンテキストのキャンセル、AfterFunc コールバック、およびリトライの goroutine は、テストが終了する前に完了するか明示的に停止される必要があります。
第四に、バージョンの違いを適切に扱えるか。Go 1.24 ではフラグ配下で実験的に synctest が公開され、Go 1.25 で安定版 API が提供されています。CI ではバージョンを固定する必要があります。
第五に、統合テストのカバレッジを維持できるか。仮想時間のテストは論理的な順序を検証するものであり、実プロセスにおける HTTP、データベース、スケジューラ、またはレース検出器の挙動を検証するものではありません。
最初に明確にすべき質問
- CI は Go 1.24 の実験的機能と Go 1.25 の安定版 API のどちらを使用していますか?
- すべてのテスト goroutine はバブル内で作成されていますか?
- リトライは
time.After、タイマー、外部スケジューラのどれを使用していますか? - キャンセル後、実行中の I/O は完了まで許容されますか、それとも即座に戻る必要がありますか?
- 実際のネットワーク遅延、データベースロック、競合状態のカバレッジは必要ですか?
- コードはクロックや依存関係のインターフェースを受け取ることができますか、それとも既存の関数をラップする必要がありますか?
30秒の回答フレームワーク
「対象の関数を synctest.Test バブル内で実行し、バックオフとデッドラインを通じて仮想時間を進め、静止状態の待機に synctest.Wait を使用します。リトライ回数、最終エラー、1回限りの AfterFunc の挙動、およびキャンセル後に処理が残らないことをアサートします。Go 1.24 では実験的フラグが必要であり、Go 1.25 では安定版 API が提供されているため、CI でバージョンを固定します。実際の HTTP、データベース、スケジューラ、および競合のチェックは、バブルによって自動的に制御されないため、別個に実行します。」
ステップ別の回答
ステップ 1: バージョンと API を固定する
Go 1.24 の testing/synctest は実験的であり GOEXPERIMENT=synctest が必要でしたが、Go 1.25 では標準の Test および Wait API が公開されています。ローカルと CI のセマンティクスが一致するように、go.mod、CI イメージ、およびコマンドライン環境を固定します。
ステップ 2: 処理をバブル内に配置する
テスト関数の外部で制御不能な処理を開始するのではなく、すべてのテスト goroutine をバブル内で作成します。バブルがキャンセル、チャネルのクローズ、リソースのクリーンアップを管理し、終了する前にすべてのタスクが戻る必要があります。
func TestRetryTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
got := runWithRetry(ctx)
synctest.Wait()
// Advance virtual time and assert retry/timeout state here.
_ = got
})
}ステップ 3: 仮想時間を進める
バックオフに実際の time.Sleep を使用しないでください。バブルのクロックを次のタイマーまたは明示的なデッドラインまで進め、Wait を呼び出して実行可能な goroutine を実行します。失敗した遷移を見落とさないよう、複数の境界を一度にスキップせず、1回進めるごとにアサーションを行います。
ステップ 4: AfterFunc とキャンセルを検証する
context.AfterFunc は、キャンセルが発生した際にコールバック goroutine を開始します。コールバック回数と確認されたエラーを記録し、キャンセル後に Wait を呼び出します。関数が繰り返しコールバックを登録する場合は、キャンセルパスが設計通りの副作用のみを発生させることをアサートします。
ステップ 5: リトライと最終結果をカバーする
2回の失敗の後に成功するなど、制御された失敗シーケンスを注入し、各バックオフと呼び出し回数をアサートします。また、デッドライン切れ、親コンテキストのキャンセル、永続的な失敗もテストします。キャンセルによって後続のリトライが停止し、エラーの分類が安定して維持される必要があります。
ステップ 6: バブルの境界を確認する
バブルの外部で開始された goroutine、システムコール、実際のネットワーク、サードパーティのランタイムは仮想時間を使用しない場合があります。それらの依存関係をインターフェースに置き換えるか、統合テストスイートでテストしてください。バブルの決定論はエンドツーエンドの保証ではありません。
ステップ 7: 他の検証ツールと組み合わせる
Synctest は論理的なタイミングをカバーします。go test -race はデータ競合をチェックし、実際の HTTP やデータベースのテストは接続、デッドライン、プロトコルの挙動をチェックします。テストが知らず知らずのうちにリークしたり低速化したりしないよう、テストの実行時間、リトライ境界、タスクの収束を追跡します。
模範解答
「まず Go のバージョンを固定します。Go 1.24 の synctest には実験的フラグが必要ですが、Go 1.25 では安定版の synctest.Test と synctest.Wait を使用します。すべてのテスト goroutine をバブル内で作成し、失敗シーケンスを注入し、バックオフとデッドラインを通じて仮想時間を進め、進めるたびに静止状態を待ち、呼び出し、エラー、コールバック、キャンセルのクリーンアップをアサートします。
実際のスリープを使用したり、バブル外のネットワーク、データベース、サードパーティの goroutine を仮想時間の処理として扱ったりはしません。それらのパスには、ロジックテスト用にインターフェースのフェイクを用意し、プロトコル、ロック、競合状態用には実際の統合テストと go test -race を用意します。テストは、終了時にバブルのタスクが残っていないことを証明する必要があります。」
よくある間違い
- バブル内で
time.Sleepを使い続ける → テストが遅く非決定論的なままになる → 仮想時間を進める。 - 最終結果のみをアサートする → リトライやキャンセルのクリーンアップが誤っている可能性がある → タイマー、呼び出し、コールバックを段階的にアサートする。
- バブルの外部で goroutine を開始する → 収束を待機できなくなる → バブルに処理を管理させる。
- 実際のネットワークを仮想時間として扱う → I/O の不安定さが残る → フェイクと独立した統合テストを使用する。
- 1.24/1.25 の API の違いを無視する → ローカルビルドと CI ビルドが乖離する → Go と実験的フラグを固定する。
- レース検出器を省略する → タイミングは正しくてもデータ競合が残る可能性がある →
go test -raceを組み合わせる。 - キャンセル後にリトライを継続する → リクエストとリソース消費が増加する → 各待機および呼び出しの前にコンテキストを確認する。
- すべての検証に最後の1回の
Waitのみを使用する → 失敗の境界が不明確になる → フェーズごとに進めて検証する。
フォローアップの質問
フォローアップ 1: synctest はリアルタイムテストを完全に置き換えますか?
いいえ。制御された並行ロジックと時間的関係を検証するものであり、クロック、ネットワーク、データベース、スケジューラは引き続き実環境でのテストが必要です。
フォローアップ 2: なぜ Wait を呼び出すのですか?
仮想時間を進めるとタイマーが期限切れになります。Wait はバブル内の実行可能な goroutine を静止状態に達するまで実行し、アサーションを安定させます。
フォローアップ 3: 永続的なブロッキングはどのようにテストしますか?
操作にデッドラインを設定し、そこまで時間を進めてエラーとクリーンアップをアサートします。キャンセル不可能な外部ブロッキングの場合は、バブル内で無限に待機するのではなく、フェイクまたは独立したタイムアウトテストを使用します。
フォローアップ 4: Go 1.24 の実験的機能を本番環境で使用できますか?
制御されたテストをサポートすることは可能ですが、実験的ステータス、フラグ、アップグレードリスクを明確にする必要があります。安定した互換性を確保するには、Go 1.25 API を優先し、CI で固定します。
フォローアップ 5: バブル外部のリークはどのように検出しますか?
終了前に完了シグナルとクローズされたリソースを確認し、-race、goroutine プロファイル、または統合タイムアウトを組み合わせます。synctest はプロセスレベルの完全なリーク検出器ではありません。
フォローアップ 6: バックオフのドリフトはどのように検出しますか?
各呼び出しの仮想タイムスタンプを記録し、期待されるシーケンスと比較します。また、最終待機のデッドライン切り捨てや早期キャンセルもテストします。