testability-obstacle について
testability-obstacleは、環境依存の処理を決定論的にテストしたいC#開発者向けのスキルです。動作を保つ最小限の本番コード変更でテスト用の接点を設け、対象を絞って検証します。
主な機能
- 01具体的なテスト障害の特定
- 02最小限の依存性シームの選択
- 03動作と公開APIの維持
- 04フェイクを使った決定論的テスト
- 05対象を絞ったビルドと検証
活用シーン
- 01ファイルを読み書きするコードのテスト
- 02時刻、乱数、プロセス状態の制御
- 03周辺コードを再設計せずテスト用シームを追加
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dotnet/ skills --skill testability-obstacleインストール後、Claude Code で/testability-obstacleを入力して読み込まれたか確認します。
詳細
テスト可能性を妨げる要因を解消する
特定の C# の動作をテストするために必要な、動作を維持する最小限の接点を導入し、その動作と接点の両方を証明する決定性のあるテストを追加します。本番コードの変更は、要求されたテストを可能にするための手段であり、周辺コードを再設計するためのものではありません。
使用する場合
- 要求されたテストで、実際のファイルシステムを読み書きしないといけない場合。
- 動作が現在時刻、遅延、乱数値、環境、コンソール、プロセス、またはその他の環境依存要素に左右される場合。
- ユーザーが安全な本番コードの接点を明示的に許可または要求している場合。
- プロセス全体に影響する変更をせずに、既存のテストから依存要素を制御できない場合。
使用しない場合
- 依存要素がすでに注入されているか、引数として渡されている場合。
code-testingを使い、既存の接点からフェイクを使用してテストを作成します。 - ユーザーがリポジトリ全体のテスト可能性の監査を求めている場合。
detect-static-dependenciesを使用します。 - ユーザーがラッパーの生成のみを求め、呼び出し箇所やテストの変更は求めていない場合。
generate-testability-wrappersを使用します。 - ユーザーが広範囲にわたる機械的な移行を求めている場合。
migrate-static-to-wrapperを使用し、その後でテストを別途生成します。 - ユーザーが
TimeProviderやIFileSystemなどの既存の代替手段を選択済みで、呼び出し箇所の移行を求めている場合。影響を受けるテストも更新するmigrate-static-to-wrapperを使用します。 - コードが C#/.NET ではない場合。
入力
| 入力 | 必須 | 説明 |
|---|---|---|
| テスト対象の動作 | はい | メソッド/ワークフローと、期待される観測可能な動作 |
| 対象範囲 | いいえ | 省略された場合は、関連性が最も高いファイル/プロジェクトを特定します |
| 許可される本番コードの変更 | いいえ | デフォルトは最小限の内部接点/コンストラクター接点です |
ワークフロー
ステップ 1:障害要因を確認する
対象となる本番コードの経路と既存のテストを確認します。決定性のあるテストを妨げる、具体的な環境依存の操作と、変更せずに維持すべき動作を特定します。単一クラスに関する要求で、リポジトリ全体の静的解析を実行してはいけません。
適切な接点がすでにある場合は、リファクタリングを中止してそれを使用します。フェイクをすでに渡せる場合、このスキルを使っても効果はありません。
ステップ 2:最小限で安全な接点を選ぶ
依存要素とリポジトリの制約に応じて選択します。
| 依存要素 | 推奨する接点 |
|---|---|
| 現在時刻/タイマー | TimeProvider を注入し、テストでは FakeTimeProvider を使用します |
| ファイルシステム | リポジトリに既存の抽象化があればそれを使用します。単一の書き込み/読み取り操作では、慣例に合う場合はデリゲートを注入し、そうでなければ 1 つのメンバーだけを持つインターフェース、またはすでに採用されている System.IO.Abstractions を使用します |
| HTTP | 既存の型指定された HttpClient/ハンドラー、または IHttpClientFactory の接点 |
| 乱数 | 生成された最終値が 1 つの場合は Func<int> を注入し、範囲の選択は実際のデフォルト実装に残します。テストで範囲引数の検証が必要な場合に限り Func<int, int, int> を注入します。複数の操作/状態がある場合は Random または最小限のジェネレーターインターフェースを注入します |
| 環境/コンソール/プロセス | 対象が使用するメンバーだけを含む最小限のインターフェース |
AsyncLocal<T> をスコープで使用するルールは、公開された static の形状を維持する必要があるすべての静的 API(時計、ファイルシステムへのアクセス、環境の参照、識別子の生成、乱数など)に適用されます。スコープは以前の値を保存し、復元する必要があります。Dispose() を無条件に null を代入する実装にしてはいけません。プロバイダー/値そのものを AsyncLocal<T> に格納してください。変更可能な Stack<T>、リスト、その他の共有された変更可能なコレクションをスロットに格納してはいけません。子実行コンテキストが同じオブジェクトを継承し、互いの入れ子状態を破損させる可能性があります。プロバイダー自体が変更可能な場合(たとえばインメモリストアやフェイクの時刻プロバイダー)は、親コンテキストから継承したインスタンスを変更するのではなく、並列の各フロー内で新しいプロバイダーを設定してください。
インスタンスクラスでは、コンストラクター注入をデフォルトとします。リポジトリの DI と命名の慣例を再利用しますが、このワークフローを満たすためだけにクラスライブラリへ DI コンテナーを追加してはいけません。 ユーザーが API の変更を許可しない限り、既存の公開コンストラクターの仕様を維持してください。実際の依存関係を既定とする、引数なしの公開コンストラクターを残し、テスト専用のデリゲート/プロバイダー用コンストラクターは、テストプロジェクトからアクセスできる範囲で最も限定的な可視性にしてください。テストを便利にするだけの理由で、この接続点を新たな公開オプション引数にしないでください。
静的クラス、または公開 API を変更できない場合は、コンストラクターやパラメーターによるインジェクションが不可能なときに限り、スコープ付きのアンビエント接続点を使用してください。オーバーライドは次の条件を満たす必要があります。
awaitをまたいで伝播すること([ThreadStatic]ではなくAsyncLocal<T>を使用)。IDisposableを返し、ネストしたスコープも含めて以前の値を復元すること。- 既定では実際の本番用依存関係を使用すること。
- テストを並列実行できなくする、プロセス全体で共有する可変のフェイクを避けること。
IDelay ラッパーを独自に作るのではなく、組み込みのフェイク時間対応オーバーロードを使用してください。
| アンビエント操作 | 置き換え |
|---|---|
Task.Delay(delay, token) | Task.Delay(delay, timeProvider, token) |
new CancellationTokenSource(delay) | new CancellationTokenSource(delay, timeProvider) |
PeriodicTimer(period) | ターゲットフレームワークで使用できる場合は new PeriodicTimer(period, timeProvider) |
遅延動作をテストするには、操作を開始し、未完了であることを確認してから、FakeTimeProvider を進め、その後に操作を待機してください。期限や境界をテストする場合は、期限の直前まで時間を進め、タスクがまだ未完了であることを確認してから、期限を越えて進めてください。開始直後に確認するだけでは、境界を検証したことにはなりません。実時間で待機することは絶対に避けてください。
ネストしたアンビエントオーバーライドでは、各スコープは開始時に有効だった値を保存し、その値を正確に一度だけ復元してください。using/finally を使用して LIFO 順にスコープを破棄してください。スロットを無条件に null にリセットしてはいけません。テストでは、内側のスコープが通常どおり終了した後に外側の値が確認できることを検証してください。また、要求されている場合は、例外によって内側のスコープが解除された後にも確認してください。スロットをクリアするだけでも偶然テストに合格してしまうことがないよう、異なる値を使用してください。また、独立した非同期フローを重ねて実行し、それぞれが自身の新しいオーバーライドだけを参照することを検証してください。環境変数の接続点をテストするために、プロセスの環境変数を変更してはいけません。
var previous = s_provider.Value;
s_provider.Value = provider;
return new RestoreScope(() => s_provider.Value = previous);
ステップ 3:動作と API の形状を維持する
本番コードの変更は機械的なものに留めてください。
- 対象の動作で使用されるメンバーだけをラップしてください。
- 既定の実装では、元の API に直接委譲してください。
- 例外、パスの処理、タイムゾーン、
DateTime.Kindを維持してください。 - ユーザーが明示的に API の変更を許可しない限り、既存の公開シグネチャを維持してください。
- ビジネスロジックをラッパーに移したり、無関係な本番コードのバグを修正したりしないでください。
決定論的なシリアライズ済みテキストは、アンビエントなプラットフォーム書式を維持する原則の例外です。ユーザーがプラットフォーム間で完全に再現可能な出力を求めている場合は、その形式で明示されている区切り文字を使用してください(指定がない場合は、リテラルの \n を使用します)。そして、そのリテラルの内容をアサートしてください。プラットフォーム固有の出力が既存の契約の一部である場合に限り、Environment.NewLine を維持してください。
時刻の置き換えについては、次のとおりです。
DateTime.UtcNow->timeProvider.GetUtcNow().UtcDateTimeDateTime.Now->timeProvider.GetLocalNow().LocalDateTimeDateTimeOffset.UtcNow->timeProvider.GetUtcNow()DateTimeOffset.Now->timeProvider.GetLocalNow()
ステップ 4:本番環境の既定値を引き続き使用する
接続点の影響を受けるコンポジションルートまたはコンストラクター呼び出しをすべて更新してください。本番環境では、引き続き実際の時刻/ファイルシステムなどを既定で使用する必要があります。プロジェクトで DI を使用している場合は、リポジトリの規約に沿ったライフタイムで既定の実装を登録してください。DI を使用していない場合は、明示的に組み立ててください。コンテナーを導入してはいけません。
既存の手動ファクトリーでは、実際の依存関係を明示的に渡してください(例:new ExpirationPolicy(TimeProvider.System))。責務をオプションのコンストラクターに移したり、ファクトリーにオプションのプロバイダーパラメーターを追加したりしないでください。
テストを作成する前に、影響を受ける本番プロジェクトをビルドしてください。ここでのコンパイルエラーはテストの問題ではなく、接続点の問題です。
ステップ 5:決定論的なテストを作成する
リポジトリに既存のテストプロジェクトがあれば、それを使用してください。存在しない場合は、まず
scaffold-dotnet-test-project を実行してください。
テストには、制御可能な依存関係を用意してください。
- 壁時計による待機ではなく、固定時刻または進められる時刻を使用する;
- 一時ファイルや実ファイルではなく、インメモリの偽ファイルシステムまたは手作りの偽物を使用する;
- 環境の変更、外部プロセス、コンソール入力、ネットワークを使用しない。
テストを作成する前に、そのテストプロジェクトを調べ、既存のフレームワーク、グローバル using、アサーションの慣例に従ってください。そのプロジェクトですでに参照されているフレームワークパッケージを使用してください。テストをコンパイルさせるために、手作りの FactAttribute、代替のテストフレームワーク型、または無関係なテストプロジェクトの配線を追加してはいけません。
要求されたビジネス上の結果と、偽の依存関係がその処理経路を駆動したことを示す、少なくとも 1 つの観測可能なやり取りまたは状態を検証してください。本番環境のデフォルトを使ったテストは、決定論的なまま実施できる場合に限って含めてください。アダプターが委譲することを証明するためだけに、実際のファイルシステムに触れてはいけません。
明示的に要求されたすべての動作とエッジケースを網羅してください。理論テストや共有ヘルパーを使ってテストスイートを簡潔にしてもかまいませんが、テスト数を減らすためにケースを省略したり、既存テストをより少数のテストに置き換えたりしてはいけません。シームは最小限にし、検証は完全なものにしてください。
動作を実現できる最も狭いシームを選んでください。File.WriteAllText の呼び出しが 1 つだけなら、実際のデフォルト実装を持つ Action<string, string> を注入できます。リポジトリの慣例や複数の操作で必要とされない限り、インターフェース、実装、フレンドアセンブリ設定、追加のプロジェクト配線を作成しないでください。
既存のシグネチャと同様に、パブリック API の表面も維持してください。テストだけを目的として、依存関係を注入するパブリックコンストラクターを追加してはいけません。現在、暗黙的なパブリック引数なしコンストラクターしかないクラスで、テストアセンブリが特定できる場合は、そのコンストラクターの動作を維持し、テスト専用コンストラクターを internal にしてください。この狭いケースでは、シームがパブリック API になるのを防ぐため、InternalsVisibleTo のエントリが正当化されます。既存のリポジトリにフレンドアセンブリの慣例があれば、それを優先してください。
既存のパブリックシームで偽物を受け取れる場合や、テストプロジェクト側で別の方法により依存関係を渡せる場合は、InternalsVisibleTo を追加しないでください。フレンドアセンブリ経由のアクセスが正当化されるのは、パブリック API を維持するために、選択した最小限のコンストラクターまたはデリゲートのシームを internal にする必要があり、かつ正確なテストアセンブリが判明している場合に限られます。
ステップ 6:完全な経路を検証する
影響を受ける本番コードのビルドと、最も範囲の狭い対象テストコマンドを実行してください。リポジトリ全体のテストコマンドは、ユーザーが広範な検証を要求している場合、リポジトリの契約でそのエントリーポイントが必須の場合、またはシームの変更が対象範囲外でも使用される共有コンポジションに影響する場合にのみ実行してください。差分を読み直し、次の点を確認してください。
- 本番コードの変更がすべてシームに必要である;
- 新しいテストが、環境に実在するリソースを使用していない;
- 現在時刻の意味とパブリックな動作が維持されている;
- 既存のテストが置き換えられたり、重複して追加されたりしていない。
終了コードだけでなく、テストの概要も確認してください。検出されたテストがゼロである場合、要求されたテスト実行を伴わないビルドのみの場合、または失敗・エラーとなったテストがある場合、作業は未完了です。成功を報告する前に、検出・実行の問題を修正して再実行してください。 新しいテストがコンパイルできない場合は、本番コードのシームを変更する前に、既存のテストフレームワークに合わせて import、アサーションのオーバーロード、または非同期テストの形式を修正してください。ソースコードで不足しているフレームワーク API を模倣してはいけません。 静的な環境依存シームの場合、完了には、置換、ネストした復元、要求されている場合の例外発生時の復元、および重複する非同期フローの分離について、テストが実行されていることが必要です。本番コードのコンパイルだけでは決して不十分です。引き継ぎ時には、成功したテスト数または要求されたテスト名を記録してください。テストが検出されなかった場合、または出力から実行が確認できない場合は、シームが検証済みだと報告するのではなく、プロジェクトまたはテストのソースを修正して再実行してください。
出力要件
簡潔な要件 | 根拠テーブルを提示してください。プロダクションコードの接続点、プロダクションでのデフォルト配線、正確なテスト名、成功したコマンドを記載してください。パッケージの復元またはビルドが検証を妨げる場合は、テストが成功したと主張せず、その阻害要因を報告してください。
検証
- 元の障害は具体的で、依頼されたパス内にありました。
- 既存の接続点が利用可能な場合は、それを再利用しました。
- 新しい抽象化で公開するメンバーは、対象の動作に必要なものだけです。
- プロダクションのデフォルト設定は、引き続き元の依存先に処理を委譲します。
- 内部テスト用の接続点で十分な場合、接続点によって公開 API を拡大していません。
- 時刻変換では、ローカル時刻/UTC と
DateTime.Kindのセマンティクスが維持されます。 - 静的なアンビエントオーバーライドは、非同期安全で、スコープ化され、ネスト可能で、元に戻せます。
- 新しいテストでは固定またはインメモリの依存関係を使用し、実際の I/O や実時間に依存しません。
- プロダクションビルドと、対象のテストまたはリポジトリのテストが成功し、少なくとも依頼されたテストが 1 件検出されています。
よくある落とし穴
| 落とし穴 | 是正措置 |
|---|---|
| 障害を確認する前にリファクタリングする | 既存の接続点を再利用し、テストを直接記述します |
| 静的 API 全体をラップする | 対象の動作で使用するメンバーだけを公開します |
UtcNowを.DateTimeで変換する | DateTimeKind.Utcを維持するため、.UtcDateTimeを使用します |
| テスト間で共有される変更可能な静的フェイク | コンストラクターインジェクションまたはスコープ付きのAsyncLocal<T>オーバーライドを使用します |
| コンテナーのないライブラリに DI を追加する | 依存関係を明示的に組み立てます |
| 手軽な方法として一時ファイルを使う | インメモリのフェイクを用意します。このシナリオでは実際の I/O は不要です |
| リファクタリング後のビルドだけで終える | 接続点を正当化した動作テストを記述して実行します |
| テストが 0 件の実行を成功と報告する | テストの検出を修正し、依頼されたテストの実行と成功を必須にします |