.NET 8のTimeProviderと、たった一つのSynchronizationContextの調整によって、実際の待機時間(sleep)で停滞していたリトライロジックのユニットテストを数秒単位で短縮できます。かつて7秒かかっていたテストが、わずか数十ミリ秒で終了するようになります。しかも、この変更による本番環境への追加コストはゼロです。
なぜ遅延が問題なのか
多くのリトライヘルパーは指数バックオフ(1秒待機、次に2秒、次に4秒...)を使用します。本番環境では、これは失敗しているエンドポイントへの過剰な負荷からサービスを保護します。しかし、テストスイートでは、同じコードが実際の時計に基づいてTask.Delayを呼び出すため、テストスレッドが実際にスリープしてしまいます。これを数十のテストに対して行うと、機能的な利点がないまま、継続的インテグレーション(CI)パイプラインの実行時間が数分も膨れ上がります。この「スリープ税(sleep tax)」は、純粋な時間の無駄です。
TimeProviderの仕組み
.NET 8では、システムクロックを抽象化したTimeProviderが導入されました。現在時刻が必要なクラスや、遅延が必要なクラスは、DateTime.UtcNowやTask.Delayを直接呼び出す代わりに、TimeProviderのインスタンスを受け取ることができます。本番環境では、実際のクロックに転送するTimeProvider.Systemを渡します。テストでは、FakeTimeProviderを提供します。このフェイクを使うと、Advance(TimeSpan)を使用して手動で時間を進めることができます。内部的にTask.Delayはプロバイダーを参照しているため、フェイクの時計を進めるだけで、保留中の遅延を即座に解消できます。
考え方はシンプルです。実際の時計を制御可能なものに置き換え、テスト対象のコードが再開されるはずの時点まで時間をジャンプさせるのです。
xUnitのSynchronizationContextの罠
この概念はコンソールアプリでは機能しますが、xUnitではデッドロックを引き起こしました。リトライヘルパーが遅延を待機(await)している間に、テストがAdvance()を呼び出したことが原因です。xUnitは、継続(continuation)をキャプチャしてテストスレッド上で実行する独自のSynchronizationContextをインストールします。Advance()によって時計が進んだとき、継続はテストスレッドではなくスレッドプールにキューイングされました。テストスレッドは時間を進め続け、シミュレートされた時計を、次のリトライが実行されるべき時点を通り過ぎてしまいました。新しいタイマーは将来の時点に設定されましたが、時計を再び進めるスレッドが残っていないため、その時点に到達することはありませんでした。その結果、テストは無期限にハングしてしまいました。
1行の修正策
テストの開始時にたった1行を追加するだけで、期待通りの動作が回復します。
SynchronizationContext.SetSynchronizationContext(null);
カスタムコンテキストをクリアすることで、awaitの継続がスレッドプール上で実行されるようになり、フェイクタイマーのコールバックが実行可能になります。この変更により、同じテストの実行時間が7秒から約36ミリ秒へと劇的に短縮されます。
さらなるメリット
アイドル状態のスリープを排除するだけでなく、フェイククロックを使用すると、タイムゾーンに依存するロジックのテストも容易になります。実際の時計が深夜0時になるのを待つことなく、日次クォータが正しい現地時間の深夜にリセットされることを検証できます。同じアプローチは、現在時刻に基づいて分岐するあらゆるコードに適用可能です。プロバイダーを注入し、Thread.SleepやTask.Delayへの隠れた呼び出しを避ければ、決定論的で高速なテストが手に入ります。
注意点
- すべての遅延は、注入された
TimeProviderを経由させる必要があります。不注意なThread.Sleepや直接のTask.Delayが残っていると、依然として実際のクロックが呼び出され、レイテンシが再導入されます。 - 依存しているライブラリが内部でプロバイダーを公開せずに
Task.Delayを呼び出している場合、より侵襲的なシム(shim)なしではタイミングを制御できません。そのような場合、メリットは限定的になる可能性があります。 - テストフォルダ内で
Task.DelayやThread.Sleepをgrepして、隠れた待機箇所を見つけ出してください。それらの呼び出しをプロバイダーを使用するようにリファクタリングすることだけが、フェイククロックを効果的に保つ唯一の方法です。
まとめ
システムクロックをTimeProviderに置き換え、xUnitのSynchronizationContextを無効にすることで、鈍重なリトライテストをほぼ瞬時に完了するチェックへと変えることができます。これによりCIリソースが解放され、時間依存のコードの検証が容易になります。必要な労力はわずか数行のコードですが、その見返りはテスト実行ごとの数秒の短縮として現れます。
