Verwenden von Shims zum Isolieren der Anwendung für Unittests

Shimtypen, eine der beiden Schlüsseltechnologien, die von Microsoft Fakes Framework verwendet werden, sind bei der Isolierung der Komponenten Ihrer App während des Testens von entscheidender Bedeutung. Sie funktionieren durch Abfangen und Umleiten von Aufrufen bestimmter Methoden, die Sie dann in Ihrem Test an benutzerdefinierten Code weiterleiten können. Mit diesem Feature können Sie das Ergebnis dieser Methoden verwalten und sicherstellen, dass die Ergebnisse während jedes Aufrufs konsistent und vorhersagbar sind, unabhängig von externen Bedingungen. Dieser Grad der Kontrolle optimiert den Testprozess und hilft dabei, zuverlässigere und präzisere Ergebnisse zu erzielen.

Verwenden Sie Shims, wenn Sie eine Grenze zwischen Ihrem Code und Assemblys erstellen müssen, die nicht Teil Ihrer Projektmappe sind. Wenn es darum geht, Komponenten Ihrer Lösung voneinander zu isolieren, wird die Verwendung von Stubs empfohlen.

(Eine ausführlichere Beschreibung von Stubs finden Sie unter Verwenden von Stubs, um Teile der Anwendung für Komponententests voneinander zu isolieren.)

Einschränkungen von Shims

Es ist wichtig zu beachten, dass Shims Einschränkungen aufweisen.

Shims können nicht für alle Typen aus bestimmten Bibliotheken in der .NET-Basisklasse verwendet werden, insbesondere mscorlib und System in .NET Framework, und in System.Runtime in .NET Core oder .NET 5 oder höher. Diese Einschränkung sollte während der Testplanungs- und Entwurfsphase berücksichtigt werden, um eine erfolgreiche und effektive Teststrategie sicherzustellen.

Erstellen eines Shims: Schritt-für-Schritt-Anleitung

Angenommen, die Komponente enthält Aufrufe von System.IO.File.ReadAllLines:

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

Erstellen einer Klassenbibliothek

  1. Öffnen Sie Visual Studio, und erstellen Sie ein Class Library-Projekt.

    Screenshot of NetFramework Class Library project in Visual Studio.

  2. Festlegen des Projektnamens HexFileReader

  3. Legen Sie den Projektmappennamen ShimsTutorial fest.

  4. Festlegen des Zielframeworks des Projekts auf .NET Framework 4.8

  5. Löschen der Standarddatei Class1.cs

  6. Fügen Sie eine neue Datei HexFile.cs hinzu, und fügen Sie die folgende Klassendefinition hinzu:

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

Erstellen eines Testprojekts

  1. Klicken Sie mit der rechten Maustaste auf die Projektmappe, und fügen Sie ein neues Projekt MSTest Test Project hinzu.

  2. Festlegen des Projektnamens TestProject

  3. Festlegen des Zielframeworks des Projekts auf .NET Framework 4.8

    Screenshot of NetFramework Test project in Visual Studio.

Hinzufügen von Fakes-Assemblys

  1. Hinzufügen eines Projektverweises auf HexFileReader

    Screenshot of the command Add Project Reference.

  2. Hinzufügen von Fakes-Assemblys

    • Im Projektmappen-Explorer:

      • Erweitern Sie für ein älteres .NET Framework-Projekt (kein SDK-Format) den Knoten Verweise Ihres Projekts für den Komponententest.

      • Erweitern Sie bei einem Projekt im SDK-Format für .NET Framework, .NET Core oder .NET 5 oder höher unter Assemblys, Projekte oder Pakete den Knoten Abhängigkeiten, um die gewünschte Assembly zu finden, die Sie als Fakes-Assembly verwenden möchten.

      • Wenn Sie in Visual Basic arbeiten, müssen Sie auf der Symbolleiste im Projektmappen-Explorer auf Alle Dateien anzeigen klicken, um den Knoten Verweise anzuzeigen.

    • Wählen Sie die Assembly System aus, die die Definition von System.IO.File.ReadAllLines enthält.

    • Wählen Sie im Kontextmenü Fakes-Assembly hinzufügen aus.

    Screnshot of the command Add Fakes Assembly.

Da der BLDvorgang zu einigen Warnungen und Fehlern führt, weil nicht alle Typen mit Shims verwendet werden können, müssen Sie den Inhalt von „Fakes\mscorlib.fakes“ ändern, um sie auszuschließen.

<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>

Einen Komponententest erstellen

  1. Ändern der Standarddatei UnitTest1.cs, um die folgende TestMethod hinzuzufügen

    [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);
        }
    }
    

    Hier ist der Projektmappen-Explorer: alle Dateien werden angezeigt

    Screenshot of Solution Explorer showing all files.

  2. Öffnen Sie den Test-Explorer, und führen Sie den Test aus.

Es ist wichtig, jeden Shimkontext ordnungsgemäß zu löschen. Als Faustregel gilt: Rufen Sie die ShimsContext.Create-Methode innerhalb einer using-Anweisung auf, um sicherzustellen, dass die registrierten Shims ordnungsgemäß gelöscht werden. Sie können beispielsweise einen Shim für eine Testmethode registrieren, die die DateTime.Now-Methode durch einen Delegaten ersetzt, der immer den 1. Januar 2000 zurückgibt. Wenn Sie vergessen, den registrierten Shim in der Testmethode zu löschen, gibt der Rest des Testlaufs immer den 1. Januar 2000 als DateTime.Now-Wert zurück. Dies mag Sie vielleicht überraschen und verwirren.


Namenskonventionen für Shimklassen

Shimklassennamen werden gebildet, indem dem ursprünglichen Typnamen Fakes.Shim vorangestellt wird. Parameternamen werden dem Methodennamen angefügt. (Sie müssen keine Assemblyverweise zu System.Fakes hinzufügen.)

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

Grundlegendes zur Funktionsweise von Shims

Shims funktionieren durch die Einführung von Umleitungen in die Codebasis der zu testenden Anwendung. Bei jedem Aufruf der ursprünglichen Methode greift das Fakes-System ein, um diesen Aufruf umzuleiten, sodass Ihr benutzerdefinierter Shimcode anstelle der ursprünglichen Methode ausgeführt wird.

Es ist wichtig zu wissen, dass diese Umleitungen dynamisch zur Laufzeit erstellt und entfernt werden. Umleitungen sollten immer innerhalb der Lebensdauer eines ShimsContext erstellt werden. Wenn der ShimsContext verworfen wird, werden auch alle aktiven Shims entfernt, die darin erstellt wurden. Um diesen Vorgang effizient zu verwalten, wird empfohlen, die Erstellung von Umleitungen innerhalb einer using-Anweisung zu kapseln.


Shims für verschiedene Arten von Methoden

Shims unterstützen verschiedene Methodentypen.

Statische Methoden

Beim Shimming statischer Methoden werden Eigenschaften, die Shims enthalten, einem Shimtyp zugeordnet. Diese Eigenschaften besitzen nur einen Setter, der zum Anfügen eines Delegaten an die Zielmethode verwendet wird. Wenn z. B. eine Klasse namens MyClass mit einer statischen Methode MyMethod verwendet wird:

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

Wir können einen Shim an MyMethod anfügen, sodass sie stets 5 zurückgibt:

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

Instanzmethoden (für alle Instanzen)

Auf ähnliche Weise wie bei statischen Methoden können auch für Instanzenmethoden Shims für alle Instanzen verwendet werden. Die Eigenschaften, die diese Shims enthalten, werden in einem geschachtelten Typ mit dem Namen „AllInstances“ platziert, um Verwechslungen zu vermeiden. Wenn wir über eine Klasse MyClass mit einer Instanzmethode MyMethodverfügen:

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

Wir können einen Shim an MyMethod anfügen, sodass sie unabhängig von der Instanz stets 5 zurückgibt:

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

Die generierte Typstruktur von ShimMyClass würde wie folgt aussehen:

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

Fakes übergibt in diesem Szenario die Laufzeitinstanz als erstes Argument des Delegaten.

Instanzmethoden (einzelne Laufzeitinstanz)

Instanzmethoden können je nach Empfänger des Aufrufs auch mit unterschiedlichen Delegaten geshimmt werden. So kann die gleiche Instanzmethode unterschiedliche Verhaltensweisen pro Typinstanz aufweisen. Die Eigenschaften zum Speichern dieser Shims sind Instanzmethoden des Shimtyps selbst. Jeder instanziierte Shimtyp ist mit einer unformatierten Instanz eines Shimtyps verknüpft.

Betrachten wir als Beispiel die MyClass-Klasse mit der MyMethod-Instanzmethode:

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

Wir können zwei Shimtypen für MyMethod erstellen, sodass die erste Methode konsistent 5 und die zweite konsistent 10 zurückgibt:

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

Die generierte Typstruktur von ShimMyClass würde wie folgt aussehen:

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

Auf die tatsächliche Shimtypinstanz kann über die Instance-Eigenschaft zugegriffen werden:

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

Der Shimtyp verfügt auch über eine implizite Konvertierung in den Typ, auf den ein Shim angewendet wurde, sodass Sie den Shimtyp direkt verwenden können:

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

Konstruktoren

Konstruktoren stellen keine Ausnahme beim Shimming dar. Sie können ebenfalls geshimmt werden, um Shimtypen an Objekte anzufügen, die in Zukunft erstellt werden. Jeder Konstruktor wird z. B. als statische Methode mit dem Namen Constructor innerhalb des Shim-Typs dargestellt. Betrachten wir eine Klasse MyClass mit einem Konstruktor, der einen Integerwert annimmt:

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

Ein Shimtyp für den Konstruktor kann so eingerichtet werden, dass unabhängig vom an den Konstruktor übergebenen Wert jede zukünftige Instanz -5 zurückgibt, wenn der Value-Getter aufgerufen wird:

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

Jeder Shimtyp macht zwei Typen von Konstruktoren verfügbar. Der Standardkonstruktor sollte verwendet werden, wenn eine neue Instanz benötigt wird, während der Konstruktor, der eine Instanz mit angewendetem Shim als Argument annimmt, nur in Konstruktorshims verwendet werden sollte:

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

Die Struktur des generierten Typs für ShimMyClass kann wie folgt veranschaulicht werden:

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

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

Zugreifen auf Basismember

Auf Shimeigenschaften von Basismembern kann zugegriffen werden, indem ein Shim für den Basistyp erstellt und die untergeordnete Instanz als Parameter an den Konstruktor der Shimbasisklasse übergeben wird.

Betrachten wir z. B.eine MyBase-Klasse mit der MyMethod-Instanzmethode und dem Untertyp MyChild:

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

public class MyChild : MyBase {
}

Ein Shim von MyBase kann eingerichtet werden, indem ein neuer ShimMyBase-Shim initiiert wird:

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

Es ist wichtig zu beachten, dass bei der Übergabe als Parameter an den Basisshimkonstruktor der untergeordnete Shimtyp implizit in die untergeordnete Instanz konvertiert wird.

Die Struktur des generierten Typs für ShimMyChild und ShimMyBase kann mit dem folgenden Code verknüpft werden:

// 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 { ... } }
}

Statische Konstruktoren

Shimtypen machen eine statische StaticConstructor-Methode verfügbar, um einen Shim auf den statischen Konstruktors eines Typs anzuwenden. Da statische Konstruktoren nur einmal ausgeführt werden, müssen Sie sicherstellen, dass der Shim konfiguriert ist, bevor auf einen Member des Typs zugegriffen wird.

Finalizer

Finalizer werden in Fakes nicht unterstützt.

Private Methoden

Der Fakes-Codegenerator erstellt Shimeigenschaften für private Methoden, die nur sichtbare Typen in der Signatur aufweisen, d.h. sichtbare Parameter- und Rückgabetypen.

Binden von Schnittstellen

Wenn ein Shimtyp eine Schnittstelle implementiert, gibt der Code-Generator eine Methode aus, die es ermöglicht, alle Member aus dieser Schnittstelle gleichzeitig zu binden.

Betrachten wir beispielsweise die MyClass-Klasse, die IEnumerable<int> implementiert:

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

Auf die Implementierungen von IEnumerable<int> in MyClass können Shims durch Aufrufen der Bind-Methode angewendet werden:

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

Die generierte Typstruktur von ShimMyClass ähnelt dem folgenden Code:

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

Ändern des Standardverhaltens

Jeder generierte Shimtyp enthält eine Instanz der IShimBehavior-Schnittstelle, auf die über die ShimBase<T>.InstanceBehavior-Eigenschaft zugegriffen werden kann. Das Verhalten wird immer dann aufgerufen, wenn ein Client einen Instanzmember aufruft, auf den nicht explizit ein Shim angewendet wurde.

Wenn kein bestimmtes Verhalten festgelegt wurde, wird standardmäßig die von der statischen ShimBehaviors.Current-Eigenschaft zurückgegebene Instanz verwendet, die in der Regel eine NotImplementedException-Ausnahme auslöst.

Sie können dieses Verhalten jederzeit ändern, indem Sie die InstanceBehavior-Eigenschaft für eine beliebige Shiminstanz anpassen. Der folgende Codeausschnitt ändert z. B. das Verhalten so, dass entweder keine Aktion ausgeführt oder der Standardwert des Rückgabetyps zurückgegeben wird, d. h. default(T):

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

Sie können auch das Verhalten für alle Instanzen mit angewendeten Shims (in denen die InstanceBehavior Eigenschaft nicht explizit definiert wurde) global ändern, indem Sie die statische ShimBehaviors.Current-Eigenschaft festlegen:

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

Identifizieren von Interaktionen mit externen Abhängigkeiten

Um zu ermitteln, wann Ihr Code mit externen Systemen oder Abhängigkeiten (als environment bezeichnet) interagiert, können Sie Shims nutzen, um allen Membern eines Typs ein bestimmtes Verhalten zuzuweisen. Dies schließt statische Methoden ein. Wenn Sie das ShimBehaviors.NotImplemented-Verhalten für die statische Behavior-Eigenschaft des Shimtyps festlegen, löst jeder Zugriff auf einen Member dieses Typs, der nicht explizit geshimmt wurde, eine NotImplementedException aus. Dies kann während des Tests als nützliches Signal dienen, das darauf hinweist, dass Ihr Code versucht, auf ein externes System oder eine Abhängigkeit zuzugreifen.

Hier sehen Sie ein Beispiel dafür, wie dies in Ihrem Komponententestcode eingerichtet wird:

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

Der Einfachheit halber wird auch eine Kurzmethode gezeigt, um den gleichen Effekt zu erzielen:

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

Aufrufen ursprünglicher Methoden innerhalb von Shimmethoden

Es kann Szenarien geben, in denen Sie möglicherweise die ursprüngliche Methode während der Ausführung der Shimmethode ausführen müssen. Sie könnten zum Beispiel Text in das Dateisystem schreiben wollen, nachdem Sie den an die Methode übergebenen Dateinamen überprüft haben.

Eine Möglichkeit, mit dieser Situation umzugehen, besteht darin, einen Aufruf der ursprünglichen Methode mithilfe eines Delegaten und ShimsContext.ExecuteWithoutShims() zu kapseln, wie im folgenden Code gezeigt:

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

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

Alternativ können Sie den Shim annullieren, die ursprüngliche Methode aufrufen und den Shim dann wiederherstellen.

// 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;

Behandeln von Parallelität mit Shimtypen

Shimtypen funktionieren in allen Threads innerhalb der AppDomain und weisen keine Threadaffinität auf. Diese Eigenschaft sollte unbedingt beachtet werden, wenn Sie einen Testrunner verwenden möchten, der Parallelität unterstützt. Es ist erwähnenswert, dass Tests mit Shimtypen nicht gleichzeitig ausgeführt werden können, obwohl diese Einschränkung nicht von der Fakes-Runtime erzwungen wird.

Shimming von System.Environment

Wenn Sie die System.Environment-Klasse shimmen möchten, müssen Sie einige Änderungen an der Datei mscorlib.fakes vornehmen. Fügen Sie nach dem Assembly-Element den folgenden Inhalt hinzu:

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

Nachdem Sie diese Änderungen vorgenommen und die Projektmappe neu erstellt haben, sind die Methoden und Eigenschaften in der System.Environment-Klasse jetzt für Shimming verfügbar. Hier sehen Sie ein Beispiel dafür, wie Sie der GetCommandLineArgsGet-Methode ein Verhalten zuweisen können:

System.Fakes.ShimEnvironment.GetCommandLineArgsGet = ...

Durch diese Änderungen haben Sie die Möglichkeit eröffnet, zu steuern und zu testen, wie Ihr Code mit Systemumgebungsvariablen interagiert – ein wesentliches Tool für umfassende Komponententests.