shim を使用して単体テストのためにアプリを分離する

shim 型は、Microsoft Fakes フレームワークで使用される 2 つの主要なテクノロジの 1 つであり、テスト中にアプリのコンポーネントを分離するのに役立ちます。 これらは、特定のメソッドへの呼び出しをインターセプトして迂回させることで機能します。その後、テスト内のカスタム コードに転送できます。 この機能を使うと、これらのメソッドの結果を管理し、外部条件に関係なく、各呼び出しでその結果を一貫した予測可能なものにすることができます。 このレベルの制御を行うことにより、テスト プロセスが合理化され、より信頼性の高い正確な結果を得るのに役立ちます。

自分のコードと、ソリューションの一部を成していないアセンブリの間に境界を作成する必要がある場合は、shim を使います。 ソリューションの各コンポーネントを互いに分離することが目的の場合は、スタブを使うことをお勧めします。

(スタブの詳しい説明については、「スタブを使用して単体テストでアプリケーションの各部分を相互に分離する」を参照してください。)

shim の制限事項

shim には独自の制限事項があることに注意してください。

.NET 基底クラスにある特定のライブラリのすべての型で shim を使用できるわけではありません。具体的には、.NET Framework の mscorlibSystem、.NET Core または .NET 5 以降の System.Runtime が該当します。 この制約をテストの計画と設計の段階で考慮して、うまくいく効果的なテスト戦略を作成する必要があります。

Shim の作成: ステップ バイ ステップ ガイド

コンポーネントに System.IO.File.ReadAllLines の呼び出しが含まれているとします。

// Code under test:
this.Records = System.IO.File.ReadAllLines(path);

クラス ライブラリを作成する

  1. Visual Studio を開き、Class Library プロジェクトを作成します

    Screenshot of NetFramework Class Library project in Visual Studio.

  2. HexFileReader というプロジェクト名を設定します

  3. ShimsTutorial というソリューション名を設定します。

  4. プロジェクトのターゲット フレームワークを .NET Framework 4.8 に設定します

  5. 既定のファイル Class1.cs を削除します

  6. 新しいファイル HexFile.cs を追加し、次のクラス定義を追加します。

    // HexFile.cs
    public class HexFile
    {
        public string[] Records { get; private set; }
    
        public HexFile(string path)
        {
            this.Records = System.IO.File.ReadAllLines(path);
        }
    }
    

テスト プロジェクトを作成する

  1. ソリューションを右クリックして、新しいプロジェクト MSTest Test Project を追加します

  2. TestProject というプロジェクト名を設定します

  3. プロジェクトのターゲット フレームワークを .NET Framework 4.8 に設定します

    Screenshot of NetFramework Test project in Visual Studio.

Fakes アセンブリの追加

  1. HexFileReader へのプロジェクト参照を追加します

    Screenshot of the command Add Project Reference.

  2. Fakes アセンブリの追加

    • ソリューション エクスプローラーで。

      • 古い .NET Framework プロジェクト (非 SDK スタイル) の場合は、単体テスト プロジェクトの [参照] ノードを展開します。

      • .NET Framework、.NET Core、または .NET 5.0 以降がターゲットである SDK スタイルのプロジェクトの場合は、[依存関係] ノードを展開し、[アセンブリ][プロジェクト]、または [パッケージ] で偽装するアセンブリを見つけます。

      • Visual Basic で作業している場合、 [参照] ノードを表示するには、ソリューション エクスプローラー ツールバーの [すべてのファイルを表示] を選択します。

    • System.IO.File.ReadAllLines の定義を含むアセンブリ System を選択します。

    • ショートカット メニューで、 [Fakes アセンブリの追加] を選択します。

    Screnshot of the command Add Fakes Assembly.

shim ですべての型を使用できるわけではないため、ビルドするといくつかの警告とエラーが発生するので、Fakes\mscorlib.fakes の内容を変更してそれらを除外する必要があります。

<Fakes xmlns="http://schemas.microsoft.com/fakes/2011/" Diagnostic="true">
  <Assembly Name="mscorlib" Version="4.0.0.0"/>
  <StubGeneration>
    <Clear/>
  </StubGeneration>
  <ShimGeneration>
    <Clear/>
    <Add FullName="System.IO.File"/>
    <Remove FullName="System.IO.FileStreamAsyncResult"/>
    <Remove FullName="System.IO.FileSystemEnumerableFactory"/>
    <Remove FullName="System.IO.FileInfoResultHandler"/>
    <Remove FullName="System.IO.FileSystemInfoResultHandler"/>
    <Remove FullName="System.IO.FileStream+FileStreamReadWriteTask"/>
    <Remove FullName="System.IO.FileSystemEnumerableIterator"/>
  </ShimGeneration>
</Fakes>

単体テストを作成する

  1. 既定のファイル UnitTest1.cs を変更して、次の TestMethod を追加します

    [TestMethod]
    public void TestFileReadAllLine()
    {
        using (ShimsContext.Create())
        {
            // Arrange
            System.IO.Fakes.ShimFile.ReadAllLinesString = (s) => new string[] { "Hello", "World", "Shims" };
    
            // Act
            var target = new HexFile("this_file_doesnt_exist.txt");
    
            Assert.AreEqual(3, target.Records.Length);
        }
    }
    

    すべてのファイルが表示されたソリューション エクスプローラーを次に示します

    Screenshot of Solution Explorer showing all files.

  2. テスト エクスプローラーを開いてテストを実行します。

各 shim コンテキストを適切に破棄することが重要です。 原則としては、登録した shim を適切に消去するために、using ステートメント内で ShimsContext.Create を呼び出します。 たとえば、DateTime.Now メソッドを常に 2000 年 1 月 1 日を返すデリゲートに置き換えるテスト メソッドのために shim を登録する場合があります。 テスト メソッド内で登録済み shim を消去し忘れた場合、テスト実行の残りの部分では、DateTime.Now 値として常に 2000 年 1 月 1 日が返されます。 これは、予想外で、混乱を招く可能性があります。


Shim クラスの名前付け規則

Shim クラスの名前は、元の型名の先頭に Fakes.Shim を付けることで構成されます。 パラメーター名がメソッド名に追加されます (System.Fakes へのアセンブリ参照を追加する必要はありません)。

    System.IO.File.ReadAllLines(path);
    System.IO.Fakes.ShimFile.ReadAllLinesString = (path) => new string[] { "Hello", "World", "Shims" };

shim のしくみを理解する

shim は、テスト対象のアプリケーションのコードベースに detour を導入することによって動作します。 元のメソッドへの呼び出しが行われるたびに、Fakes システムが介入してその呼び出しをリダイレクトするため、元のメソッドではなくカスタム shim コードが実行されます。

これらの detour は実行時に動的に作成および削除されることに注意してください。 detour は、常に ShimsContext の有効期間内に作成される必要があります。 ShimsContext が破棄されると、その中で作成されたすべてのアクティブな shim も削除されます。 これを効率的に管理するには、using ステートメント内で detour の作成をカプセル化することをお勧めします。


さまざまな種類のメソッドの shim

shim では、さまざまな種類のメソッドがサポートされています。

静的メソッド

静的メソッドの shim を作成する場合、shim を保持するプロパティは shim 型内に格納されます。 これらのプロパティはセッターのみを持ち、対象のメソッドにデリゲートをアタッチするために使用されます。 たとえば、静的メソッド MyMethod を含む MyClass というクラスがある場合:

//code under test
public static class MyClass {
    public static int MyMethod() {
        ...
    }
}

shim を MyMethod にアタッチして、常に 5 を返すようにすることができます。

// unit test code
ShimMyClass.MyMethod = () => 5;

インスタンス メソッド (すべてのインスタンス用)

静的メソッドと同様に、すべてのインスタンスに対してインスタンス メソッドの shim を作成できます。 これらの shim を保持するプロパティは、混乱を避けるために、AllInstances という名前の入れ子にされた型に配置されます。 インスタンス メソッド MyMethod を持つクラス MyClass がある場合:

// code under test
public class MyClass {
    public int MyMethod() {
        ...
    }
}

MyMethod に shim をアタッチして、どのインスタンスでも常に 5 を返すようにすることができます。

// unit test code
ShimMyClass.AllInstances.MyMethod = () => 5;

生成される ShimMyClass の型構造は、次のようになります。

// Fakes generated code
public class ShimMyClass : ShimBase<MyClass> {
    public static class AllInstances {
        public static Func<MyClass, int>MyMethod {
            set {
                ...
            }
        }
    }
}

このシナリオでは、Fakes によって実行時インスタンスがデリゲートの第 1 引数として渡されます。

インスタンス メソッド (1 つの実行時インスタンス)

インスタンス メソッドは、呼び出しの受信側に応じて、異なるデリゲートを使って shim を作成することもできます。 そうすることで、同じインスタンス メソッドに、その型のインスタンスごとに異なる動作をさせることができます。 これらの shim を保持するプロパティは、それ自体が shim 型のインスタンス メソッドです。 インスタンス化された各 shim 型は、shim が適用された型の生のインスタンスにリンクされます。

たとえば、クラス MyClass にインスタンス メソッド MyMethod があるとします。

// code under test
public class MyClass {
    public int MyMethod() {
        ...
    }
}

MyMethod に対して 2 つの shim 型を作成し、1 つ目は常に 5 を返し、2 つ目は常に 10 を返すようにすることができます。

// unit test code
var myClass1 = new ShimMyClass()
{
    MyMethod = () => 5
};
var myClass2 = new ShimMyClass { MyMethod = () => 10 };

生成される ShimMyClass の型構造は、次のようになります。

// Fakes generated code
public class ShimMyClass : ShimBase<MyClass> {
    public Func<int> MyMethod {
        set {
            ...
        }
    }
    public MyClass Instance {
        get {
            ...
        }
    }
}

shim が適用された実際の型のインスタンスには、Instance プロパティを通じてアクセスできます。

// unit test code
var shim = new ShimMyClass();
var instance = shim.Instance;

shim 型には、shim が適用された型への暗黙的な変換も含まれるため、shim 型を直接使用できます。

// unit test code
var shim = new ShimMyClass();
MyClass instance = shim; // implicit cast retrieves the runtime instance

コンストラクター

コンストラクターも shim の対象にすることができます。これらに対しても shim を適用して、将来作成されるオブジェクトに shim 型をアタッチすることができます。 たとえば、shim 型内で、すべてのコンストラクターは Constructor という名前の静的メソッドとして表されます。 整数を受け取るコンストラクターを持つクラス MyClass を考えてみましょう。

public class MyClass {
    public MyClass(int value) {
        this.Value = value;
    }
    ...
}

このコンストラクターの shim 型を設定して、コンストラクターに渡される値に関係なく、今後すべてのインスタンスで Value のゲッターが呼び出されたときに -5 を返すようにすることができます。

// unit test code
ShimMyClass.ConstructorInt32 = (@this, value) => {
    var shim = new ShimMyClass(@this) {
        ValueGet = () => -5
    };
};

各 shim 型は 2 種類のコンストラクターを公開します。 新しいインスタンスが必要な場合には既定のコンストラクターを使い、shim が適用されたインスタンスを引数として受け取るコンストラクターはコンストラクターの shim でのみ使う必要があります。

// unit test code
public ShimMyClass() { }
public ShimMyClass(MyClass instance) : base(instance) { }

生成される ShimMyClass の型の構造は次のようになります。

// Fakes generated code
public class ShimMyClass : ShimBase<MyClass>
{
    public static Action<MyClass, int> ConstructorInt32 {
        set {
            ...
        }
    }

    public ShimMyClass() { }
    public ShimMyClass(MyClass instance) : base(instance) { }
    ...
}

基本メンバーへのアクセス

基本メンバーの shim プロパティにアクセスするには、基本型の shim を作成し、基本 shim クラスのコンストラクターに子インスタンスを渡します。

たとえば、インスタンス メソッド MyMethod とサブタイプ MyChild を持つクラス MyBase を考えてみます。

public abstract class MyBase {
    public int MyMethod() {
        ...
    }
}

public class MyChild : MyBase {
}

新しい ShimMyBase の shim を初期化することによって、MyBase の shim を設定できます。

// unit test code
var child = new ShimMyChild();
new ShimMyBase(child) { MyMethod = () => 5 };

子 shim 型は、基本 shim コンストラクターへのパラメーターとして渡されるときに、暗黙的に子インスタンスに変換されることに注意してください。

生成される ShimMyChildShimMyBase の型の構造は、次のコードに例えることができます。

// Fakes generated code
public class ShimMyChild : ShimBase<MyChild> {
    public ShimMyChild() { }
    public ShimMyChild(Child child)
        : base(child) { }
}
public class ShimMyBase : ShimBase<MyBase> {
    public ShimMyBase(Base target) { }
    public Func<int> MyMethod
    { set { ... } }
}

静的コンストラクター

shim 型は、型の静的コンストラクターに shim を適用するために、静的メソッド StaticConstructor を公開します。 静的コンストラクターは 1 回だけ実行されるため、型のいずれかのメンバーがアクセスされる前に shim が構成されるようにする必要があります。

ファイナライザー

ファイナライザーは、Fakes ではサポートされません。

プライベート メソッド

Fakes コード ジェネレーターは、シグネチャに参照可能な型だけを持つプライベート メソッドの shim プロパティを作成します。つまり、パラメーターの型と戻り値の型が参照可能です。

バインド インターフェイス

shim が適用された型がインターフェイスを実装する場合、コード ジェネレーターは、そのインターフェイスのすべてのメンバーを一度にバインドできるメソッドを生成します。

たとえば、IEnumerable<int> を実装する MyClass クラスがあるとします。

public class MyClass : IEnumerable<int> {
    public IEnumerator<int> GetEnumerator() {
        ...
    }
    ...
}

Bind メソッドを呼び出すことで、MyClass の IEnumerable<int> の実装に shim を適用できます。

// unit test code
var shimMyClass = new ShimMyClass();
shimMyClass.Bind(new List<int> { 1, 2, 3 });

生成される ShimMyClass の型の構造は、次のコードのようになります。

// Fakes generated code
public class ShimMyClass : ShimBase<MyClass> {
    public ShimMyClass Bind(IEnumerable<int> target) {
        ...
    }
}

既定の動作を変更する

生成された各 shim 型には、IShimBehavior インターフェイスのインスタンスが含まれます。これには ShimBase<T>.InstanceBehavior プロパティを介してアクセスできます。 明示的に shim が適用されていないインスタンス メンバーをクライアントが呼び出すたびに、この動作が呼び出されます。

既定では、特定の動作が設定されていない場合は、静的プロパティ ShimBehaviors.Current によって返されるインスタンスが使用され、通常は NotImplementedException 例外がスローされます。

この動作は、shim インスタンスの InstanceBehavior プロパティを調整することでいつでも変更できます。 たとえば、次のコード スニペットでは、何も行わないか、戻り値の型の既定値 (つまり、default(T)) を返すように動作が変更されます。

// unit test code
var shim = new ShimMyClass();
//return default(T) or do nothing
shim.InstanceBehavior = ShimBehaviors.DefaultValue;

静的プロパティ ShimBehaviors.Current を設定することで、shim が適用されたすべてのインスタンス (InstanceBehavior プロパティが明示的に定義されていない) の動作をグローバルに変更することもできます。

// unit test code
// change default shim for all shim instances where the behavior has not been set
ShimBehaviors.Current = ShimBehaviors.DefaultValue;

外部依存関係との対話を特定する

コードが外部のシステムまたは依存関係 (environment と呼ばれます) と対話するタイミングを特定するために、shim を使用して、ある型のすべてのメンバーに特定の動作を割り当てることができます。 これには静的メソッドが含まれます。 shim 型の静的プロパティ BehaviorShimBehaviors.NotImplemented の動作を設定すると、明示的に shim が適用されていないその型のメンバーにアクセスするたびに NotImplementedException がスローされます。 これは、テスト中に便利なシグナルとして使うことができます。コードが外部のシステムまたは依存関係にアクセスしようとしていることがわかります。

単体テストのコードでこれを設定する方法の例を次に示します。

// unit test code
// Assign the NotImplementedException behavior to ShimMyClass
ShimMyClass.Behavior = ShimBehaviors.NotImplemented;

利便性のために、同じ効果を実現する短縮形のメソッドも用意されています。

// Shorthand to assign the NotImplementedException behavior to ShimMyClass
ShimMyClass.BehaveAsNotImplemented();

shim メソッド内から元のメソッドを呼び出す

シナリオによっては、shim メソッドの実行中に元のメソッドを実行する必要があるかもしれません。 たとえば、メソッドに渡されたファイル名を検証した後で、ファイル システムにテキストを書き込む場合が考えられます。

この状況を処理する方法の 1 つは、次のコードに示すように、デリゲートと ShimsContext.ExecuteWithoutShims() を使って元のメソッドへの呼び出しをカプセル化することです。

// unit test code
ShimFile.WriteAllTextStringString = (fileName, content) => {
  ShimsContext.ExecuteWithoutShims(() => {

      Console.WriteLine("enter");
      File.WriteAllText(fileName, content);
      Console.WriteLine("leave");
  });
};

または、shim を無効化し、元のメソッドを呼び出した後、shim を復元することもできます。

// unit test code
ShimsDelegates.Action<string, string> shim = null;
shim = (fileName, content) => {
  try {
    Console.WriteLine("enter");
    // remove shim in order to call original method
    ShimFile.WriteAllTextStringString = null;
    File.WriteAllText(fileName, content);
  }
  finally
  {
    // restore shim
    ShimFile.WriteAllTextStringString = shim;
    Console.WriteLine("leave");
  }
};
// initialize the shim
ShimFile.WriteAllTextStringString = shim;

shim 型を使用したコンカレンシーの処理

shim 型は AppDomain 内のすべてのスレッドで動作し、スレッド アフィニティを持ちません。 コンカレンシーをサポートするテスト ランナーを使用する予定の場合は、この性質を念頭に置くことが非常に重要です。 shim 型を含むテストは並行で実行できないことに注意してください。ただし、この制限は Fakes ランタイムによって適用されるわけではありません。

System.Environment に shim を適用する

System.Environment クラスに shim を適用する場合は、mscorlib.fakes ファイルにいくつかの変更を加える必要があります。 Assembly 要素の後に、次の内容を追加します。

<ShimGeneration>
    <Add FullName="System.Environment"/>
</ShimGeneration>

これらの変更を行ってソリューションをリビルドすると、System.Environment クラスのメソッドとプロパティを shim として使用できるようになります。 GetCommandLineArgsGet メソッドに動作を割り当てる方法の例を次に示します。

System.Fakes.ShimEnvironment.GetCommandLineArgsGet = ...

これらの変更を行うことで、コードがシステム環境変数と対話する方法を制御およびテストすることが可能になります。これは、包括的な単体テストに不可欠なツールです。