Usare gli shim per isolare l'app per gli unit test

I tipi shim, una delle due tecnologie chiave usate dal Microsoft Fakes Framework, sono fondamentali per isolare i componenti dell'app durante il processo di test. Funzionano intercettando e deviando le chiamate a metodi specifici, che è quindi possibile indirizzare al codice personalizzato all'interno del test. Questa funzionalità consente di gestire il risultato di questi metodi in modo da garantire che i risultati siano coerenti e prevedibili durante ogni chiamata, indipendentemente dalle condizioni esterne. Questo livello di controllo semplifica il processo di test e aiuta a ottenere risultati più affidabili e accurati.

Utilizzare gli shim quando è necessario stabilire una separazione tra il proprio codice e gli assembly che non fanno parte della soluzione. Quando l'obiettivo è isolare i componenti della soluzione l'uno dall'altro, è consigliabile usare stub . Per una descrizione dettagliata degli stub, vedere Usare gli stub per isolare le parti dell'applicazione l'una dall'altra per gli unit test.

Questo articolo fornisce una guida dettagliata per l'uso di tipi shim per deviare le chiamate a metodi specifici nel codice di unit test.

Informazioni sulle limitazioni dei tipi con shim

Esistono alcune limitazioni quando si lavora con gli shim. Non possono essere usati in tutti i tipi di determinate librerie nella classe di base .NET, in particolare:

  • mscorlib e System nel framework di .NET
  • System.Runtime in .NET Core o .NET 5+

Quando si progetta la strategia di test, è necessario tenere conto di questo vincolo per garantire test unitari efficaci e ben riusciti.

Crea uno shim

Si supponga che il componente contenga chiamate al System.IO.File.ReadAllLines metodo :

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

Per preparare il componente per gli unit test, completare i passaggi descritti nelle procedure seguenti.

Creare una libreria di classi

Creare una nuova soluzione e un progetto iniziale per la libreria di classi.

  1. Nella finestra Visual Studio Start Window (File>Start Window), creare un progetto Class Library selezionando il modello per C# o Visual Basic.

    Screenshot del modello di progetto libreria di classi per .NET Framework e C# in Visual Studio.

    Screenshot del modello di progetto Libreria di classi per .NET Framework e C# in Visual Studio 2022.

  2. Configurare il nuovo progetto:

    • Impostare la libreria di classi Nome progetto su HexFileReader.
    • Impostare il nome della soluzione su ShimsTutorial.
    • Impostare il framework Target su .NET Framework 4.8.

    Fare clic su Crea.

  3. Dopo aver aperto il progetto, individuare il file predefinito Class1.cs in Esplora soluzioni ed eliminare il file.

  4. Aggiungere un file denominato HexFile.cs e immettere la definizione di classe seguente:

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

Aggiungere un progetto di unit test

Aggiungere un altro progetto alla soluzione per gli unit test.

  1. In Esplora soluzioni fare clic con il pulsante destro del mouse sulla soluzione ShimsTutorial e selezionare Aggiungi>Nuovo Project.

  2. Nella finestra Avvia finestra creare un Unit Test Project project selezionando il modello.

    Screenshot del modello di progetto unit test Microsoft per .NET Framework e C# in Visual Studio.

    Screenshot del modello di progetto unit test Microsoft per .NET Framework e C# in Visual Studio 2022.

  3. Configurare il nuovo progetto:

    • Impostare il test di unità nome progetto a TestProject.
    • Impostare il framework Target su .NET Framework 4.8.

    Fare clic su Crea.

aggiungere l'assembly Fakes

Aggiungere un riferimento al progetto HexFileReader.

  1. In Esplora soluzioni espandere il nodo TestProject, fare clic con il pulsante destro del mouse sul nodo References e selezionare Aggiungi riferimento.

    Screenshot che illustra come aggiungere un riferimento dal progetto di unit test alla classe in Visual Studio.

    Screenshot che illustra come aggiungere un riferimento dal progetto di unit test alla classe in Visual Studio 2022.

    1. Nel riquadro sinistro della finestra Gestione riferimenti selezionare la sezione Progetti .

    2. Nel riquadro centrale selezionare la casella di controllo per il HexFileReader progetto e selezionare OK.

  2. Aggiungi l'assembly Fakes.

    1. In Esplora soluzioni individuare il nodo contenitore per l'assembly:

      • Per un progetto .NET Framework meno recente (non in stile SDK), espandere il nodo del progetto di unit test e quindi espandere il nodo References.

      • Per un progetto di tipo SDK destinato a .NET Framework, .NET Core o .NET 5+, espandere il nodo Dependencies per individuare l'assembly da simulare in Assemblies, Projects o Packages.

      • Per Visual Basic, selezionare Show All Files nella barra degli strumenti Esplora soluzioni per visualizzare il nodo References.

    2. Selezionare l'assembly System contenente la definizione di System.IO.File.ReadAllLines.

    3. Fare clic con il pulsante destro del mouse sul System nodo e scegliere Aggiungi assembly Fakes.

      Screenshot che illustra come aggiungere l'assembly Fakes per il progetto in Visual Studio.

      Screenshot che illustra come aggiungere l'assembly Fakes per il progetto in Visual Studio 2022.

    Il processo di compilazione genera avvisi ed errori per i tipi non supportati per l'uso con shim.

    Al termine della compilazione, Esplora soluzioni viene aggiornato per visualizzare un nodo Fakes per il progetto di unit test.

  3. Selezionare il Fakes\mscorlib.fakes file e sostituire il codice XML per escludere i tipi non supportati:

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

Creare un unit test

Aggiungi un test unitario al progetto.

  1. Aggiornare il file predefinito TestProject\UnitTest1.cs fornito dal modello di progetto.

    Individuare la sezione di codice seguente nel file e sostituirla con il frammento specificato:

    [TestMethod]
    public void TestMethod1()
    {
    }
    

    Frammento di sostituzione:

    [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);
       }
    }
    
  2. Per visualizzare tutti gli assembly Fakes per lo unit test, selezionare Show All Files nella barra dei menu Esplora soluzioni:

    Schermata di Esplora soluzioni in Visual Studio 2022 che mostra tutti i file, tra cui gli assembly Fakes.

  3. Aprire Esplora test ed eseguire il test.

È fondamentale smaltire correttamente ogni contesto shim. Come regola pratica, chiamare il metodo ShimsContext.Create all'interno di un'istruzione using per garantire la corretta rimozione degli shim registrati. Ad esempio, è possibile registrare uno shim per un metodo di test che sostituisce il metodo DateTime.Now con un delegato che restituisce sempre la data del 1° gennaio 2000. Se si dimentica di cancellare lo shim registrato nel metodo di test, il resto dell'esecuzione del test restituisce sempre il 1° gennaio 2000 come DateTime.Now valore. Questo risultato potrebbe essere sorprendente e confuso.

Esaminare le convenzioni di denominazione per le classi shim

I nomi delle classi Shim usano il Fakes.Shim prefisso seguito dal nome del tipo originale. I nomi dei parametri vengono aggiunti al nome del metodo. Non è necessario aggiungere alcun riferimento all'assembly a System.Fakes.

System.IO.File.ReadAllLines(path);

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

Comprendere come funzionano gli shim

Gli shim operano introducendo deviazioni nella codebase dell'applicazione sottoposta a test. Ogni volta che viene eseguita una chiamata al metodo originale, il sistema Fakes interviene per reindirizzare la chiamata, causando l'esecuzione del codice shim personalizzato anziché del metodo originale.

È importante notare che queste deviazioni vengono create e rimosse in modo dinamico in fase di esecuzione. Le deviazioni devono essere sempre create entro la durata di un oggetto ShimsContext. Quando shimsContext viene eliminato, vengono rimossi anche tutti gli shim attivi creati al suo interno. Per gestire questa progettazione in modo efficiente, è consigliabile incapsulare la creazione di deviazioni all'interno di un'istruzione using .

Esplora gli shim per vari tipi di metodi

Gli shim supportano vari tipi di metodi.

Metodi statici

Quando si applicano shim ai metodi statici, le proprietà che contengono gli shim sono contenute in un tipo di shim. Queste proprietà possiedono solo un metodo setter, che viene utilizzato per assegnare un delegato al metodo mirato.

Ad esempio, per una classe denominata MyClass con un metodo MyMethodstatico :

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

È possibile collegare uno shim a MyMethod in modo che restituisca costantemente 5:

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

Metodi di istanza (per tutte le istanze)

Analogamente ai metodi statici, i metodi di istanza possono anche essere adattati per tutte le istanze. Le proprietà che contengono questi shim vengono inserite in un tipo annidato denominato AllInstances per evitare confusione.

Per una classe MyClass con un metodo di istanza MyMethod:

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

È possibile collegare uno shim a MyMethod in modo che restituisca in modo coerente 5, indipendentemente dall'istanza:

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

Il frammento di codice seguente mostra la struttura del tipo generato di ShimMyClass:

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

In questo scenario, Fakes passa l'istanza di runtime come primo argomento del delegate.

Metodi di istanza (singola istanza in fase di esecuzione)

Anche i metodi di istanza possono essere sottoposti a shimming utilizzando delegati diversi, a seconda dell'oggetto ricevente della chiamata. Questo approccio consente allo stesso metodo di istanza di presentare comportamenti diversi per ogni istanza del tipo. Le proprietà che contengono questi shim sono metodi d'istanza del tipo di shim stesso. Ogni tipo di shim istanziato è collegato a un'istanza grezza di una tipologia sottoposta a shim.

Ad esempio, data una classe MyClass con un metodo di istanza MyMethod:

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

È possibile creare due tipi di shim per MyMethod in modo che il primo restituisca sempre 5 e il secondo restituisca sempre 10:

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

Il frammento di codice seguente mostra la struttura del tipo generato di ShimMyClass:

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

È possibile accedere all'istanza effettiva del tipo con shim tramite la proprietà Instance:

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

Il tipo shim include anche una conversione implicita nel tipo oggetto dello shim, che consente di utilizzare direttamente il tipo shim:

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

Esplora i costruttori di shim

I costruttori non fanno eccezione all'uso degli shim. Possono essere configurati con uno shim per associare tipi di shim agli oggetti che verranno creati in seguito. Ad esempio, ogni costruttore è rappresentato come un metodo statico denominato Constructor all'interno del tipo shim.

Si consideri una classe MyClass con un costruttore che accetta un numero intero:

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

È possibile configurare un tipo shim per questo costruttore in modo che, indipendentemente dal valore passato al costruttore, ogni istanza futura restituisce -5 quando viene richiamato il Value getter:

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

Ogni tipo di shim prevede due tipi di costruttori:

  • Quando è necessaria una nuova istanza, usare il costruttore predefinito.
  • Quando si dispone di uno shim del costruttore, usare il costruttore che accetta un'istanza sottoposta a shim come argomento.
// Unit test code
public ShimMyClass() { }
public ShimMyClass(MyClass instance) : base(instance) { }

Il codice seguente illustra il tipo generato per :ShimMyClass

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

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

Accedere ai membri di base

È possibile accedere alle proprietà shim dei membri di base creando uno shim per il tipo di base. Quindi, passare l'istanza figlia al costruttore della classe shim di base.

Si consideri, ad esempio, una classe MyBase con un metodo MyMethod di istanza e un sottotipo MyChild:

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

public class MyChild : MyBase {
}

È possibile configurare uno shim di MyBase avviando un nuovo shim ShimMyBase.

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

È importante notare che, quando si passa lo shim figlio come parametro al costruttore dello shim di base, il tipo dello shim figlio viene convertito implicitamente nell'istanza figlia.

La struttura del tipo generato per ShimMyChild e ShimMyBase è simile al codice seguente:

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

Costruttori statici

I tipi shim espongono un metodo StaticConstructor statico per effettuare lo shim del costruttore statico di un tipo. Poiché i costruttori statici vengono eseguiti una sola volta, è necessario assicurarsi che lo shim sia configurato prima che si acceda a qualsiasi membro del tipo.

Finalizzatori

I finalizzatori non sono supportati in Fakes.

Metodi privati

Il generatore di codice Fakes crea proprietà shim per i metodi privati che hanno nella firma solo tipi visibili, cioè tipi di parametro e tipo di ritorno.

Interfacce di binding

Quando un tipo sottoposto a shim implementa un'interfaccia, il generatore di codice emette un metodo che permette di collegare tutti i membri di quell'interfaccia in una sola volta.

Ad esempio, data una classe MyClass che implementa IEnumerable<int>:

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

È possibile eseguire lo shim delle implementazioni di IEnumerable<int> in MyClass chiamando il Bind metodo :

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

La struttura del tipo generato di ShimMyClass è simile al codice seguente:

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

Modificare il comportamento predefinito

Ogni tipo di shim generato include un'istanza dell'interfaccia IShimBehavior accessibile tramite la proprietà ShimBase<T>.InstanceBehavior. Questo comportamento viene richiamato ogni volta che un client chiama un membro dell'istanza non sottoposto a shim in modo esplicito.

Per impostazione predefinita, se non è impostato alcun comportamento specifico, la progettazione usa l'istanza restituita dalla proprietà statica ShimBehaviors.Current , che in genere genera un'eccezione NotImplementedException .

È possibile modificare questo comportamento in qualsiasi momento modificando la proprietà InstanceBehavior per qualsiasi istanza shim.

Ad esempio, è possibile modificare il comportamento in modo che non esemplifichi nulla o restituisca il valore predefinito del tipo restituito, default(T):

// Unit test code
var shim = new ShimMyClass();

//Return default(T) or do nothing
shim.InstanceBehavior = ShimBehaviors.DefaultValue;

È inoltre possibile modificare globalmente il comportamento di tutte le istanze shim, in cui la proprietà InstanceBehavior non è definita esplicitamente, impostando la proprietà statica ShimBehaviors.Current:

// Unit test code
// Change default shim for all shim instances where the behavior isn't set
ShimBehaviors.Current = ShimBehaviors.DefaultValue;

Identificare le interazioni con dipendenze esterne

Per identificare quando il codice interagisce con sistemi esterni o dipendenze (detto environment), è possibile usare gli shim per assegnare un comportamento specifico a tutti i membri di un tipo (inclusi i metodi statici). Impostando il comportamento ShimBehaviors.NotImplemented sulla proprietà statica Behavior del tipo shim, qualsiasi accesso a un membro di tale tipo che non è stato sottoposto esplicitamente a shim solleva un'eccezione NotImplementedException. Questo risultato può fungere da segnale utile durante il test, a indicare che il codice sta tentando di accedere a un sistema esterno o a una dipendenza.

Ecco un esempio di come configurare questo formato di shim nel codice di unit test:

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

Per praticità, viene fornito anche un metodo abbreviato per ottenere lo stesso effetto:

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

Richiamare metodi originali dall'interno dei metodi shim

In alcuni scenari potrebbe essere necessario eseguire il metodo originale durante l'esecuzione del metodo shim. Ad esempio, è possibile scrivere testo nel file system dopo aver convalidato il nome file passato al metodo .

Un approccio per gestire questa situazione consiste nell'incapsulare una chiamata al metodo originale usando un delegato e il ShimsContext.ExecuteWithoutShims() metodo :

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

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

In alternativa, è possibile nullificare lo shim, chiamare il metodo originale e quindi ripristinare lo 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;

Gestisci la concorrenza con i tipi di shim

I tipi shim operano su tutti i thread nell'AppDomain e non sono associati ad alcun thread specifico. Questa caratteristica è cruciale da tenere presente se prevedi di utilizzare un runner di test che supporta la concorrenza. Vale la pena notare che i test che coinvolgono tipi shim non possono essere eseguiti contemporaneamente, anche se il runtime Fakes non applica questa restrizione.

Usare gli shim con System.Environment

Per eseguire lo shim della System.Environment classe, è necessario modificare il mscorlib.fakes file.

Individuare l'elemento \<Assembly> e aggiungere il contenuto seguente dopo la definizione dell'elemento:

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

Dopo aver apportato modifiche e aver ricompilato la soluzione, i metodi e le proprietà nella classe System.Environment sono ora disponibili per l'applicazione degli shim.

Ecco un esempio di come assegnare un comportamento al GetCommandLineArgsGet metodo :

System.Fakes.ShimEnvironment.GetCommandLineArgsGet = ...

Quando si apportano queste modifiche, si ottiene la possibilità di controllare e testare il modo in cui il codice interagisce con le variabili di ambiente di sistema, che è uno strumento essenziale per unit test completi.