Utiliser des shims pour isoler votre application à des fins de tests unitaires

Les types shim, l’une des deux technologies clés utilisées par Microsoft Fakes Framework, jouent un rôle important dans l’isolation des composants de votre application pendant les tests. Ils fonctionnent en interceptant et en redirigeant les appels vers des méthodes spécifiques, que vous pouvez ensuite diriger vers du code personnalisé au sein de votre test. Cette fonctionnalité vous permet de gérer le résultat de ces méthodes, en veillant à ce que les résultats soient cohérents et prévisibles pendant chaque appel, quelles que soient les conditions externes. Ce niveau de contrôle simplifie le processus de test et aide à obtenir des résultats plus fiables et précis.

Utilisez des shims lorsque vous devez créer une limite entre votre code et les assemblys qui ne font pas partie de votre solution. Lorsque l’objectif est d’isoler les composants de votre solution les uns des autres, l’utilisation de stubs est recommandée.

(Pour obtenir une description détaillée des stubs, consultez Utilisation de stubs pour isoler des parties de votre application les unes des autres pour des tests unitaires.)

Limitations des shims

Il est important de noter que les shims ont leurs limitations.

Les shims ne peuvent pas être utilisés sur tous les types de certaines bibliothèques de la classe de base .NET, en particulier mscorlib et System dans .NET Framework, et dans System.Runtime dans .NET Core ou .NET 5+. Cette contrainte doit être prise en compte pendant la phase de planification et de conception des tests pour garantir une stratégie de test efficace et réussie.

Création d’un Shim : guide pas à pas

Supposons que votre composant contienne des appels à System.IO.File.ReadAllLines :

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

Créer une bibliothèque de classes

  1. Ouvrez Visual Studio et créez un projet Class Library

    Screenshot of NetFramework Class Library project in Visual Studio.

  2. Définir le nom du projet HexFileReader

  3. Définir le nom de la solution ShimsTutorial.

  4. Définir le framework cible du projet sur .NET Framework 4.8

  5. Supprimer le fichier par défaut Class1.cs

  6. Ajoutez un nouveau fichier HexFile.cs et ajoutez la définition de classe suivante :

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

Créer un projet de test

  1. Cliquer avec le bouton droit sur la solution et ajouter un nouveau projet MSTest Test Project

  2. Définir le nom du projet TestProject

  3. Définir le framework cible du projet sur .NET Framework 4.8

    Screenshot of NetFramework Test project in Visual Studio.

Ajouter un assembly Fakes

  1. Ajouter une référence de projet à HexFileReader

    Screenshot of the command Add Project Reference.

  2. Ajouter un assembly Fakes

    • Dans l’Explorateur de solutions,

      • Pour un projet .NET Framework (non SDK) plus ancien, développez le nœud Références de votre projet de test unitaire.

      • Pour un projet de type SDK ciblant le .NET Framework, .NET Core, ou .NET 5+, développez le nœud Dépendances pour rechercher l’assembly à simuler sous Assemblys, Projets ou Packages.

      • Si vous utilisez Visual Basic, sélectionnez Afficher tous les fichiers dans la barre d’outils de l’Explorateur de solutions pour voir le nœud Références.

    • Sélectionnez l’assembly System qui contient la définition de System.IO.File.ReadAllLines.

    • Dans le menu contextuel, sélectionnez Ajouter un assembly Fakes.

    Screnshot of the command Add Fakes Assembly.

Étant donné que la génération génère des avertissements et des erreurs, car tous les types ne peuvent pas être utilisés avec des shims, vous devrez modifier le contenu de Fakes\mscorlib.fakes pour les exclure.

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

Créer un test unitaire

  1. Modifier le fichier par défaut UnitTest1.cs pour ajouter les éléments TestMethod suivants

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

    Voici l’Explorateur de solutions affichant tous les fichiers

    Screenshot of Solution Explorer showing all files.

  2. Ouvrez l’Explorateur de tests et exécutez le test.

Il est essentiel de supprimer correctement chaque contexte de shim. En règle générale, appelez ShimsContext.Create dans une instruction using pour garantir l’effacement approprié des shims inscrits. Par exemple, vous pouvez inscrire un shim pour une méthode de test qui remplace la méthode DateTime.Now par un délégué qui retourne toujours le premier janvier 2000. Si vous oubliez d’effacer le shim inscrit dans la méthode de test, le reste de la série de tests retourne toujours le 1er janvier 2000 en tant que valeur DateTime.Now. Cela peut être surprenant et déroutant.


Conventions d’affectation de noms pour les classes Shim

Les noms de classe de shim sont obtenus en ajoutant le préfixe Fakes.Shim au nom de type d'origine. Les noms de paramètres sont ajoutés au nom de la méthode. (Vous ne devez ajouter aucune référence d’assembly à System.Fakes)

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

Présentation du fonctionnement des shims

Les shims fonctionnent en introduisant des détours dans la base de code de l’application testée. Chaque fois qu’il existe un appel à la méthode d’origine, le système Fakes intervient pour rediriger cet appel, ce qui entraîne l’exécution de votre code shim personnalisé au lieu de la méthode d’origine.

Il est important de noter que ces détours sont créés et supprimés dynamiquement au moment de l’exécution. Les détours doivent toujours être créés dans la durée de vie de ShimsContext. Lorsque shimsContext est supprimé, tous les shims actifs qui ont été créés dans celui-ci sont également supprimés. Pour gérer cela efficacement, il est recommandé d’encapsuler la création de détours dans une instruction using.


Shims pour différents types de méthodes

Les shims prennent en charge différents types de méthodes.

Méthodes statiques

Lorsque vous effectuez un shim des méthodes statiques, les propriétés qui contiennent des shims sont hébergées dans un type de shim. Ces propriétés possèdent uniquement un setter, qui est utilisé pour attacher un délégué à la méthode ciblée. Par exemple, si nous avons une classe appelée MyClass avec une méthode statique MyMethod :

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

Nous pouvons attacher un shim à MyMethod pour qu’il retourne constamment 5 :

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

Méthodes d'instance (pour toutes les instances)

Comme les méthodes statiques, les méthodes d'instance peuvent aussi faire l'objet d'un shim pour toutes les instances. Les propriétés qui contiennent ces shims sont placées dans un type imbriqué nommé AllInstances pour éviter toute confusion. Si nous avons une classe MyClass avec une méthode d’instance MyMethod :

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

Nous pouvons attacher un shim à MyMethod pour qu’il retourne 5 de manière cohérente, quelle que soit l’instance :

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

La structure de type générée de ShimMyClass devrait s’afficher comme suit :

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

Dans ce scénario, Fakes passe l’instance de runtime comme premier argument du délégué.

Méthodes d’instance (instance à runtime unique)

Les méthodes d’instance peuvent également faire l’objet d’un shim à l’aide de différents délégués, en fonction du récepteur de l’appel. Cela permet à la même méthode d'instance d'avoir des comportements différents par instance du type. Les propriétés permettant de configurer ces shims sont des méthodes d'instance du type shim lui-même. Chaque type de shim instancié est lié à une instance brute d’un type faisant l’objet d’un shim.

Par exemple, prenons une classe MyClass avec une méthode d'instance MyMethod :

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

Nous pouvons créer deux types de shim pour MyMethod pour que le premier retourne 5 et la seconde retourne 10 de manière cohérente :

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

La structure de type générée de ShimMyClass devrait s’afficher comme suit :

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

L'instance du type ayant fait l'objet d'un shim véritable est accessible via la propriété Instance :

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

Le type shim inclut également une conversion implicite vers le type faisant l’objet d’un shim, ce qui vous permet d’utiliser directement le type shim :

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

Constructeurs

Les constructeurs ne font pas exception au shim ; ils peuvent également faire l’objet d’un shim pour attacher des types de shim à des objets qui seront créés à l’avenir. Par exemple, chaque constructeur est représenté sous la forme d’une méthode statique, nommée Constructor, dans le type de shim. Prenons une classe MyClass avec un constructeur qui accepte un entier :

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

Un type shim pour le constructeur peut être configuré de telle sorte que, quelle que soit la valeur passée au constructeur, chaque instance future retourne -5 lorsque le getter Value est appelé :

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

Chaque type de shim expose deux types de constructeurs. Le constructeur par défaut doit être utilisé lorsqu’une nouvelle instance est nécessaire, tandis que le constructeur qui prend une instance faisant l’objet d’un shim en tant qu’argument ne doit être utilisé que dans les shims du constructeur :

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

La structure du type généré pour ShimMyClass peut être illustrée comme suit :

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

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

Accès aux membres de base

Les propriétés shim des membres de base peuvent être atteintes en créant un shim pour le type de base et en plaçant l’instance enfant dans le constructeur de la classe shim de base.

Par exemple, considérez une classe MyBase avec une méthode d’instance MyMethod et un sous-type MyChild :

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

public class MyChild : MyBase {
}

Un shim de MyBase peut être configuré en lançant un nouveau shim ShimMyBase :

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

Notez que le type shim enfant est implicitement converti en instance enfant quand il est passé comme paramètre au constructeur de shim de base.

La structure du type généré pour ShimMyChild et ShimMyBase peut être semblable au code suivant :

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

Constructeurs statiques

Les types shim exposent une méthode statique StaticConstructor pour effectuer un shim sur le constructeur statique d'un type. Étant donné que les constructeurs statiques sont exécutés une fois seulement, vous devez vous assurer que le shim est configuré avant d’accéder à n’importe quel membre du type.

Finaliseurs

Les finaliseurs ne sont pas pris en charge dans Fakes.

Méthodes privées

Le générateur de code Fakes crée des propriétés de shim pour les méthodes privées dont la signature comporte seulement des types visibles, autrement dit des types de paramètres et un type de retour visibles.

Interfaces de liaison

Quand un type ayant fait l'objet d'un shim implémente une interface, le générateur de code émet une méthode qui lui permet de lier tous les membres de cette interface à la fois.

Par exemple, prenons une classe MyClass qui implémente IEnumerable<int> :

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

Vous pouvez effectuer un shim avec les implémentations de IEnumerable<int> dans MyClass en appelant la méthode Bind :

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

La structure du type généré de ShimMyClass ressemble au code suivant :

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

Modifier le comportement par défaut

Chaque type de shim généré inclut une instance de l’interface IShimBehavior, accessible via la propriété ShimBase<T>.InstanceBehavior. Ce comportement est appelé chaque fois qu’un client appelle un membre d’instance qui n’a pas explicitement eu un shim.

Par défaut, si aucun comportement spécifique n’a été défini, il utilise l’instance retournée par la propriété statique ShimBehaviors.Current, qui lève généralement une exception NotImplementedException.

Vous pouvez modifier ce comportement à tout moment en ajustant la propriété InstanceBehavior pour n’importe quelle instance de shim. Par exemple, l’extrait de code suivant modifie le comportement pour ne rien faire ou retourner la valeur par défaut du type de retour, c’est-à-dire default(T) :

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

Vous pouvez également modifier globalement le comportement de toutes les instances faisant l’objet d’un shim ( où la propriété InstanceBehavior n’a pas été définie explicitement) en définissant la propriété statique ShimBehaviors.Current :

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

Identification des interactions avec les dépendances externes

Pour vous aider à identifier quand votre code interagit avec des systèmes externes ou des dépendances (appelés « environment »), vous pouvez utiliser des shims pour affecter un comportement spécifique à tous les membres d’un type. Cela inclut des méthodes statiques. En définissant le comportement ShimBehaviors.NotImplemented sur la propriété statique Behavior du type shim, tout accès à un membre de ce type qui n’a pas explicitement eu un shim lève NotImplementedException. Cela peut servir de signal utile pendant le test, indiquant que votre code tente d’accéder à un système externe ou à une dépendance.

Voici un exemple de configuration de ce code dans votre code de test unitaire :

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

Pour des raisons pratiques, une méthode abrégée est également fournie pour obtenir le même effet :

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

Appel de méthodes originales à partir de méthodes Shim

Il peut y avoir des scénarios où vous devrez peut-être exécuter la méthode d’origine pendant l’exécution de la méthode shim. Par exemple, il se peut que vous vouliez écrire le texte dans le système de fichiers après avoir validé le nom de fichier passé à la méthode.

Une approche pour gérer cette situation consiste à encapsuler un appel à la méthode d’origine à l’aide d’un délégué et de ShimsContext.ExecuteWithoutShims(), comme illustré dans le code suivant :

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

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

Vous pouvez également nullifier le shim, appeler la méthode d’origine, puis restaurer le 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;

Gestion de l’accès concurrentiel avec les types Shim

Les types shim fonctionnent sur tous les threads dans AppDomain et ne possèdent pas d’affinité de thread. Gardez cette propriété à l’esprit si vous envisagez d’utiliser un exécuteur de test qui prend en charge la concurrence. Il est important de noter que les tests impliquant des types shim ne peuvent pas s’exécuter simultanément, bien que cette restriction ne soit pas appliquée par le runtime Fakes.

Shimming System.Environment

Si vous souhaitez effectuer un shim sur la classe System.Environment, vous devez apporter des modifications au fichier mscorlib.fakes. Après l’élément Assembly, ajoutez le contenu suivant :

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

Une fois que vous avez apporté ces modifications et reconstruit la solution, les méthodes et les propriétés de la classe System.Environment sont désormais disponibles pour un shim. Voici un exemple de la façon dont vous pouvez affecter un comportement à la méthode GetCommandLineArgsGet :

System.Fakes.ShimEnvironment.GetCommandLineArgsGet = ...

En effectuant ces modifications, vous avez ouvert la possibilité de contrôler et de tester la façon dont votre code interagit avec les variables d’environnement système, un outil essentiel pour les tests unitaires complets.