generate-testability-wrappers について
静的APIに依存する.NETコードをテストしやすくするスキルです。組み込み抽象の採用や最小限のラッパー生成を支援し、対象フレームワークと構成方法に応じて抽象の選択、依存性注入、登録、決定的なテストを案内します。
主な機能
- 01組み込みまたは独自抽象の選択
- 02最小限のラッパーインターフェース生成
- 03依存性注入と登録の案内
- 04決定的なテストパターン
活用シーン
- 01テスト用の静的な時刻呼び出し置換
- 02EnvironmentやConsole依存のラップ
- 03プロセス依存コードのテスト
- 04静的APIを保ったテスト用シーム
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ dotnet/ skills --skill generate-testability-wrappersインストール後、Claude Code で/generate-testability-wrappersを入力して読み込まれたか確認します。
詳細
テスト可能性を高めるラッパーを生成する
テストできない静的依存関係に対して、ラッパーインターフェース、デフォルト実装、DI サービス登録コードを生成します。.NET に組み込みの抽象化(TimeProvider、IHttpClientFactory)がすでにある静的 API については、組み込みの抽象化の導入を案内します。組み込みの代替手段がない静的 API については、カスタムの最小限のラッパーを生成します。
使用するタイミング
detect-static-dependenciesの実行後、ラップする静的 API を特定したとき- 静的 API を注入可能な抽象化に置き換えて、クラスをテスト可能にするようユーザーから依頼されたとき
TimeProvider(.NET 8 以降)またはSystem.IO.Abstractionsを導入するときEnvironment.*、Console.*、Process.*用のカスタムラッパーを作成するとき- シグネチャを変更できないため、公開済みの静的 API にアンビエントな差し込み口が必要なとき
使用しないタイミング
- ユーザーがまず静的 API を見つけたい場合(
detect-static-dependenciesを使用してください) - ユーザーが呼び出し箇所を一括置換したい場合(
migrate-static-to-wrapperを使用してください) - 静的 API がすでにインターフェースの背後にある場合
対象がすでに注入されたインターフェースまたは組み込みの抽象化を使用している場合は、そこで終了します。 その差し込み口に対して、2つ目のラッパー、プロジェクト、登録、テストを追加しないでください。
DI パッケージが不足しているからといって、それだけでアンビエントな差し込み口が必要になるわけではありません。インスタンス化可能なクラスでは、コンストラクターインジェクションを優先し、明示的に組み立てるか、要求された登録を示してください。API が静的でシグネチャを静的なままにする必要がある場合、または呼び出し元による構築/DI の変更をユーザーが明示的に禁止している場合は、ステップ 5 を使用してください。
入力
| 入力 | 必須 | 説明 |
|---|---|---|
| 静的 API のカテゴリ | はい | カテゴリ:time、filesystem、environment、network、console、process のいずれか |
| ターゲットフレームワーク | はい | .csproj の TargetFramework(利用できる組み込みの抽象化に影響します) |
| コンポジション | いいえ | 既存の DI フレームワーク、明示的/手動での構築、または不変の静的 API |
| 名前空間 | いいえ | 生成するラッパーコードの対象名前空間 |
ワークフロー
ステップ 1:抽象化の方針を決める
カテゴリとターゲットフレームワークに基づいて決定します。
| カテゴリ | .NET 8 以降 | .NET 6~7 | .NET Framework |
|---|---|---|---|
| 時刻 | TimeProvider(組み込み) | Microsoft.Bcl.TimeProvider NuGet パッケージ経由の TimeProvider | カスタムの ISystemClock |
| ファイルシステム | System.IO.Abstractions(NuGet) | 同上 | 同上 |
| HTTP | IHttpClientFactory(組み込み) | 同上 | 同上 |
| 環境 | カスタムの IEnvironmentProvider | 同上 | 同上 |
| コンソール | カスタムの IConsole | 同上 | 同上 |
| プロセス | カスタムの IProcessRunner | 同上 | 同上 |
この表で選ぶのは「どの抽象化を使うか」です。テスト対象のコードにどう適用するかは、別の観点です。
- インスタンス化可能なクラス:現在の呼び出し元が手動でオブジェクトを組み立てている場合も、コンストラクターインジェクションを使用します。
- 既存のコンテナー:その規約に沿って、コンパイル可能な登録を追加します。
- 変更できない公開静的 API/シグネチャ:ステップ 5 のアンビエントな差し込み口を使用します。
ホストビルダー、IServiceCollection、既存の登録、構築箇所を確認してください。現在コンテナーがないという理由だけで、「静的のままにする必要がある」と推測しないでください。
ステップ 2:組み込みの抽象化(時刻、HTTP)の導入を生成する
TimeProvider(.NET 8+)
ラッパーコードは不要です。次の4項目をすべて完了してください。本番環境での登録、コンストラクターインジェクション、FakeTimeProvider を使ったテスト、テスト用パッケージです。
- DI に登録します:
builder.Services.AddSingleton(TimeProvider.System);
- クラスに注入します:
public class OrderProcessor(TimeProvider timeProvider)
{
public bool IsExpired(Order order)
=> timeProvider.GetUtcNow() > order.ExpiresAt;
}
FakeTimeProviderを使ってテストします:
// Requires Microsoft.Extensions.TimeProvider.Testing NuGet
var fakeTime = new FakeTimeProvider(new DateTimeOffset(2026, 1, 15, 0, 0, 0, TimeSpan.Zero));
var processor = new OrderProcessor(fakeTime);
fakeTime.Advance(TimeSpan.FromDays(1));
Assert.True(processor.IsExpired(order));
アサーションでは、フェイクの時刻を固定または進めた後に、時刻に依存する結果が得られることを証明する必要があります。FakeTimeProvider を構築するだけではテストになりません。コンテナーがないプロジェクトでも、対象がインスタンス化可能なクラスであれば、TimeProvider を注入し、本番環境で TimeProvider.System を使って明示的に構築してください。カスタムの静的クロックに置き換えないでください。
導入が完了したとする前に、リポジトリに次の必須成果物がすべて含まれているか、回答で提示されているか確認してください。テスト用パッケージ参照、本番環境での構成/登録、影響を受けるすべてのコンストラクター呼び出し、実行可能なフェイク時刻テストです。これらの統合ポイントを1つでも省略したコード例は、ガイダンスであり、導入の完了ではありません。
TimeProvider(.NET 8 より前)
ガイド:NuGet パッケージ Microsoft.Bcl.TimeProvider をインストールします。API は上記と同じです。
IHttpClientFactory
ラッパーコードは不要です。builder.Services.AddHttpClient<MyService>() で型付きクライアントを登録し、クラスのコンストラクターに HttpClient を直接注入します。HTTP 操作に呼び出し元のトークンを渡して、キャンセルを維持してください。
テストでは、SendAsync が決定的な HttpResponseMessage を返す、完全なフェイク HttpMessageHandler を用意し、そのハンドラーで HttpClient を構築して、ネットワークにアクセスせずに型付きクライアントを実行してください。タスクで実装が求められている場合は、対象を絞ったテストをコンパイルして実行してください。概略的なハンドラーメソッドだけで終わらせないでください。
本番環境で型付きクライアントの登録を使用している場合は、同じ登録パイプラインをテストしてください。ServiceCollection でプライマリハンドラーを構成し、型付きクライアントを解決して呼び出します。HttpClient を手動で構築するテストではクラスは検証できますが、タスクで導入を求められた DI 登録は検証できません。
ステップ 3:カスタムラッパーを生成する(Environment、Console、Process)
組み込みの抽象化がないカテゴリでは、次のテンプレートに従ってください:
インターフェイス — 最小限のサーフェスを定義する
コードベースで実際に検出されたメソッドだけを含めてください。考えられるすべてのメンバーに対するラッパーを生成しないでください。使用されているものだけをラップします。
状態を持たない操作指向のインターフェイスを優先してください。たとえば、呼び出し元がプロセスの開始、待機、終了コードの返却だけを必要とする場合は、Process のライフサイクルを漏らす状態を持つラッパーではなく、単一の Run 操作を公開します。
namespace <Namespace>;
/// <summary>
/// Abstraction over <static class> for testability.
/// </summary>
public interface I<WrapperName>
{
// One method per detected static call
<return type> <MethodName>(<parameters>);
}
デフォルト実装 — 実際の静的メンバーに委譲する
namespace <Namespace>;
/// <summary>
/// Default implementation that delegates to <static class>.
/// </summary>
public sealed class <WrapperName> : I<WrapperName>
{
public <return type> <MethodName>(<parameters>)
=> <StaticClass>.<Method>(<arguments>);
}
DI 登録
// In Program.cs or Startup.cs:
builder.Services.AddSingleton<I<WrapperName>, <WrapperName>>();
登録は要件として扱い、概要の文章だけで済ませないでください。既存の登録箇所がある場合は、リポジトリ内のそこに追加します。ない場合は、コンパイル可能な正確なステートメントを示し、呼び出し元がどこに配置すべきか明記してください。状態を持たない委譲ラッパーはシングルトンにします。状態を保持する必要がある場合は、より短いライフタイムが必要な理由を説明してください。
ステップ 4:ファイルシステムラッパーの導入を生成する
カスタムラッパーよりも、実績のある NuGet パッケージ System.IO.Abstractions を優先してください:
- パッケージをインストールします:
dotnet add package System.IO.Abstractions
- DI に登録します:
builder.Services.AddSingleton<IFileSystem, FileSystem>();
- クラスに
IFileSystemを注入します:
public class ConfigLoader(IFileSystem fileSystem)
{
public string LoadConfig(string path)
=> fileSystem.File.ReadAllText(path);
}
MockFileSystemを使ってテストします:
dotnet add <TestProject> package System.IO.Abstractions.TestingHelpers
var mockFs = new MockFileSystem(new Dictionary<string, MockFileData>
{
{ "/config.json", new MockFileData("{\"key\": \"value\"}") }
});
var loader = new ConfigLoader(mockFs);
Assert.Equal("{\"key\": \"value\"}", loader.LoadConfig("/config.json"));
パッケージを優先する導入では、この方針のみを使用してください。両方のパッケージ参照を追加し、本番環境で IFileSystem を使用して、FileSystem を登録するか明示的に構成し、コンシューマーを実行する前に MockFileSystem にデータをシードします。2つ目のカスタムファイルシステムインターフェイスを生成してはならず、要求された読み取り/書き込み動作を証明しなくてもテストが通るような、データ未設定のモックを提示しないでください。
ステップ 5:シグネチャを維持するアンビエントコンテキストを生成する
API を静的なままにする必要がある場合、またはリリース済みのシグネチャでは依存関係を受け取れない場合に、このパターンを使用します。
public static class Clock
{
private static readonly AsyncLocal<Func<DateTime>?> s_override = new();
public static DateTime UtcNow
=> s_override.Value?.Invoke() ?? TimeProvider.System.GetUtcNow().UtcDateTime;
internal static IDisposable Override(DateTime fixedUtcTime)
{
if (fixedUtcTime.Kind != DateTimeKind.Utc)
throw new ArgumentException("The override must be UTC.", nameof(fixedUtcTime));
var previous = s_override.Value;
s_override.Value = () => fixedUtcTime;
return new Scope(previous);
}
private sealed class Scope : IDisposable
{
private readonly Func<DateTime>? _previous;
private bool _disposed;
public Scope(Func<DateTime>? previous)
{
_previous = previous;
}
public void Dispose()
{
if (_disposed)
return;
s_override.Value = _previous;
_disposed = true;
}
}
}
主なトレードオフ:AsyncLocal<T> により並列テストが互いに干渉しません。本番環境でのコストは呼び出しごとの null チェック 1 回です。static readonly フィールドのコストは実質的にありません。
実際の移行でそれぞれ問題を起こしたため、このパターンでは次の 3 つの特性を維持する必要があります。
- オーバーライドのスコープを限定し、元に戻せるようにします。 以前の値を復元する
IDisposableを返し、テストで固定した時刻が次のテストに漏れないようにします。単独のセッターや、各呼び出し箇所で手動のtry/finallyを使う方法では、その負担がすべてのテスト作成者にかかります。 [ThreadStatic]ではなく、必ずAsyncLocal<T>を使用します。[ThreadStatic]はawaitをまたいで引き継がれないため、オーバーライドがテストの途中で気付かないうちに消えてしまいます。- 置き換えるメンバーのセマンティクスを維持します。
DateTime.UtcNowをローカル時刻の取得元に置き換えると、既存の呼び出し元や保存された値が依存するDateTimeKindが変わります。UtcNowにはGetUtcNow()を、NowにはGetLocalNow()を組み合わせてください。 - テストアセンブリからオーバーライドにアクセスできることを確認します。
internalのオーバーライドは、テストアセンブリに対する正確なInternalsVisibleToを本番プロジェクトに追加しない限り、別のテストアセンブリからアクセスできません。それができない場合は、公開 API の変更が許可されているときに限り、すでに公開されているシームを使用してください。アクセスできないメンバーをテストから呼び出す例を示してはいけません。
同じ形は、時刻以外の静的メンバーにも使えます。実際の静的呼び出しを TimeProvider.System.GetUtcNow() に置き換え、オーバーライド用スロット、破棄可能なスコープ、元のセマンティクスを維持します。
ステップ 6:生成ファイルを配置する
プロジェクトの既存の規約に従ってファイルを生成します。
Abstractions/またはInterfaces/フォルダーがある場合は、インターフェイスをそこに配置します。Infrastructure/またはServices/フォルダーがある場合は、実装をそこに配置します。- それ以外の場合は、静的メンバーを使用するコードと同じ場所にファイルを作成します。
必ず次を生成します。
- インターフェイスファイル(または組み込みの抽象化を採用する手順)
- デフォルト実装ファイル
- 既存の登録箇所に適用するか、正確なコンポジションポイントに示した、コンパイル可能な DI 登録
- アンビエントリソースを使わずにコンシューマーを実行する、決定的な置き換えの例または焦点を絞ったテスト
アンビエントシームの方法では、登録を一切行わないでください。登録先となるコンテナーはなく、それでも登録を提案することこそ、そもそもユーザーがシームを求めた原因となった失敗パターンです。
完了を報告する前に、成果物にプロンプトで要求された項目がすべて含まれていることを確認してください。特に、登録コードを追加も提示もしていないのに「シングルトン登録」と要約したり、コンシューマーがフェイクを受け取る方法を示さずにテスト可能だと主張したりしないでください。 コンソールラッパーでは、プロンプトを取得し、返される入力も提供するフェイクを使ってコンシューマーを実行してください。ビルドやバナーだけの実行では、プロンプトのフローを検証できません。
検証
- 生成されたインターフェースが、実際に検出された静的メンバーのみをラップしている(推測によるものではない)
- デフォルト実装が、動作を変更せずに実際の静的メンバーへ処理を委譲している
- DI 登録で、ステートレスなラッパーには
AddSingleton、ステートフルなラッパーにはAddTransientを使用している - 既存のライブラリがある場合、NuGet パッケージを推奨している(System.IO.Abstractions など)
- .NET 8 以降では、独自の
ISystemClockよりもTimeProviderを推奨している - TimeProvider の採用に、インジェクション、本番環境での構成、
FakeTimeProvider、そのテスト用パッケージ、および時間に依存する結果のアサーションが含まれている - HTTP の採用に、完全なフェイクハンドラーのテストが含まれ、キャンセル処理が維持されている
- インジェクション経路では、登録または明示的な構成がコンパイル可能であり、実際のアンビエント依存関係を使わずに利用側のコードをフェイクで示している
- アンビエントコンテキストのパターンに、
AsyncLocal<T>、以前の値を復元するスコープ付きのIDisposable、およびトレードオフの説明が含まれている - アンビエントシーム経路では、
IServiceCollectionの登録を提案せず、別のテストアセンブリからオーバーライドにアクセスでき、置き換えるメンバーの戻り値の型とセマンティクス(UtcNowとNowの違い、およびDateTimeKind)が維持されている
よくある落とし穴
| 落とし穴 | 解決策 |
|---|---|
| 「DI パッケージがない」ことを「アンビエントにする必要がある」と捉える | インスタンス化可能なクラスにインジェクションし、明示的に構成します。Step 5 は静的 API やシグネチャを維持する必要がある API に限定します |
| 静的クラスのメンバーをすべてラップする | コードベースで実際に呼び出されているメソッドのみをラップします |
| .NET 8 以降で独自の時間ラッパーを使う | 組み込みの TimeProvider を使用します |
| 独自のファイルシステムラッパーを作る | 実績があり、機能が充実した NuGet パッケージの System.IO.Abstractions を優先します |
| シングルトンで十分なのにスコープを登録する | ステートレスなラッパーには AddSingleton を使用します |
| テストヘルパーパッケージを忘れる | 時間には Microsoft.Extensions.TimeProvider.Testing、ファイルシステムには System.IO.Abstractions.TestingHelpers を使用します |
AsyncLocal を使わずにアンビエントコンテキストを扱う | 非同期処理を使わない [ThreadStatic] は async/await で動作しなくなります。必ず AsyncLocal<T> を使用します |
| 内部アンビエントオーバーライドを外部テストから見せる | 正確なフレンドアセンブリを追加するか、認可されたパブリックシームを使用します。テストプロジェクトをコンパイルします |