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

I tipi shim, una delle due tecnologie chiave usate da Microsoft Fakes Framework, sono fondamentali per isolare i componenti dell'app durante i 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, assicurandosi 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.

Usare gli shim quando è necessario creare un limite tra il 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 più dettagliata degli stub, vedere Usare gli stub per isolare le parti dell'applicazione l'una dall'altra per il testing unità.

Limitazioni degli shim

È importante notare che gli shim presentano limitazioni.

Gli shim non possono essere usati in tutti i tipi di determinate librerie nella classe di base .NET, in particolare mscorlib e system in .NET Framework e in System.Runtime in .NET Core o .NET 5+. Questo vincolo deve essere preso in considerazione durante la fase di pianificazione e progettazione dei test per garantire una strategia di test efficace ed efficace.

Creazione di uno shim: guida dettagliata

Si supponga che il componente contenga delle chiamate a System.IO.File.ReadAllLines:

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

Creare una libreria di classi

  1. Aprire Visual Studio e creare un Class Library progetto

    Screenshot del progetto Libreria di classi NetFramework in Visual Studio.

  2. Impostare il nome del progetto HexFileReader

  3. Impostare il nome ShimsTutorialdella soluzione .

  4. Impostare il framework di destinazione del progetto su .NET Framework 4.8

  5. Eliminare il file predefinito Class1.cs

  6. Aggiungere un nuovo file HexFile.cs e aggiungere 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);
        }
    }
    

Creare un progetto di test

  1. Fare clic con il pulsante destro del mouse sulla soluzione e aggiungere un nuovo progetto MSTest Test Project

  2. Impostare il nome del progetto TestProject

  3. Impostare il framework di destinazione del progetto su .NET Framework 4.8

    Screenshot del progetto NetFramework Test in Visual Studio.

Aggiungere l'assembly Fakes

  1. Aggiungere un riferimento al progetto a HexFileReader

    Screenshot del comando Aggiungi riferimento al progetto.

  2. Aggiungere l'assembly Fakes

    • In Esplora soluzioni,

      • Per un progetto .NET Framework precedente (stile non SDK), espandere il nodo Riferimenti del progetto di unit test.

      • Per un progetto in stile SDK destinato a .NET Framework, .NET Core o .NET 5+, espandere il nodo Dipendenze per trovare l'assembly che si vuole simulare in Assembly, Progetti o Pacchetti.

      • Se si usa Visual Basic, selezionare Mostra tutti i file nella barra degli strumenti Esplora soluzioni per visualizzare il nodo Riferimenti.

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

    • Nel menu di scelta rapida selezionare Aggiungi assembly Fakes.

    Screnshot del comando Aggiungi assembly Fakes.

Poiché la compilazione genera alcuni avvisi ed errori perché non tutti i tipi possono essere usati con shim, è necessario modificare il contenuto di Fakes\mscorlib.fakes per escluderli.

<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 uno unit test

  1. Modificare il file UnitTest1.cs predefinito per aggiungere quanto segue 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);
        }
    }
    

    Ecco il Esplora soluzioni che mostra tutti i file

    Screenshot di Esplora soluzioni che mostra tutti i file.

  2. Aprire Esplora test ed eseguire il test.

È fondamentale eliminare correttamente ogni contesto shim. Come regola generale, chiamare l'interno ShimsContext.Create di un'istruzione using per garantire una corretta cancellazione degli shim registrati. Ad esempio, si potrebbe registrare uno shim per un metodo di test che sostituisce il metodo DateTime.Now con un delegato che restituisce sempre il primo gennaio 2000. Se si dimentica di cancellare lo shim registrato nel metodo di test, il resto dell'esecuzione del test restituirà sempre il primo gennaio 2000 come DateTime.Now valore. Ciò potrebbe sorprendere e confondere.


Convenzioni di denominazione per le classi Shim

I nomi delle classi shim vengono creati aggiungendo un prefisso Fakes.Shim al nome del tipo originale. I nomi dei parametri vengono aggiunti al nome del metodo. Non è necessario aggiungere riferimenti di assembly a System.Fakes.

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

Informazioni sul funzionamento degli 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 tale 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 all'interno di esso. Per gestirlo in modo efficiente, è consigliabile incapsulare la creazione di deviazioni all'interno di un'istruzione using .


Shim per tipi di metodi differenti

Gli shim supportano vari tipi di metodi.

Metodi statici

Quando i metodi statici shimming, le proprietà che contengono shim vengono alloggiate all'interno di un tipo shim. Queste proprietà possiedono solo un setter, che viene usato per associare un delegato al metodo di destinazione. Ad esempio, se è disponibile 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 sottoposti a shim per tutte le istanze. Le proprietà che contengono questi shim vengono inserite in un tipo annidato denominato AllInstances per evitare confusione. Se è disponibile una classe MyClass con un metodo MyMethoddi istanza :

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

La struttura del tipo generato di ShimMyClass viene visualizzata nel modo seguente:

// 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 delegato.

Metodi di istanza (Istanza di runtime singolo)

I metodi di istanza possono anche essere sottoposti a shim usando delegati diversi, a seconda del ricevitore della chiamata. In questo modo lo stesso metodo di istanza può presentare comportamenti diversi per ogni istanza del tipo. Le proprietà che contengono questi shim sono metodi di istanza del tipo shim stesso. Ogni tipo shim di cui è stata creata un'istanza è collegato a un'istanza non elaborata di un tipo sottoposto a shim.

Si consideri, ad esempio, una classe MyClass con un metodo di istanza MyMethod:

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

È possibile creare due tipi shim per in MyMethod modo che il primo restituisca costantemente 5 e il secondo restituisce 10 in modo coerente:

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

La struttura del tipo generato di ShimMyClass viene visualizzata nel modo seguente:

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

È possibile accedere all'istanza effettiva del tipo sottoposto a 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 sottoposto a shim, consentendo di usare direttamente il tipo shim:

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

Costruttori

I costruttori non fanno eccezione a shimming; possono anche essere sottoposti a shim per associare tipi shim a oggetti che verranno creati in futuro. Ad esempio, ogni costruttore viene rappresentato come 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 il costruttore in modo che, indipendentemente dal valore passato al costruttore, ogni istanza futura restituirà -5 quando viene richiamato il getter Value:

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

Ogni tipo shim espone due tipi di costruttori. Il costruttore predefinito deve essere usato quando è necessaria una nuova istanza, mentre il costruttore che accetta un'istanza sottoposta a shim come argomento deve essere usato solo negli shim del costruttore:

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

La struttura del tipo generato per ShimMyClass può essere illustrata nel modo seguente:

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

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

Accesso ai membri di base

È possibile raggiungere le proprietà shim dei membri di base creando uno shim per il tipo di base e immettendo l'istanza figlio nel 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 {
}

Uno shim di MyBase può essere configurato avviando un nuovo ShimMyBase shim:

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

È importante notare che quando viene passato come parametro al costruttore shim di base, il tipo shim figlio viene convertito in modo implicito nell'istanza figlio.

La struttura del tipo generato per ShimMyChild e ShimMyBase può essere 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 statico StaticConstructor per sottoporre a shim il costruttore statico di un tipo. Poiché i costruttori statici vengono eseguiti una sola volta, è necessario assicurarsi che lo shim sia configurato prima di accedere a qualsiasi membro del tipo.

Finalizzatori

I finalizzatori non sono supportati in Fakes.

Metodi privati

Il generatore di codice Fakes crea le proprietà dello shim per i metodi privati che hanno solo tipi visibili nella firma, ovvero i tipi di parametri e il tipo restituito visibile.

Interfacce di binding

Quando un tipo sottoposto a shim implementa un'interfaccia, il generatore di codice genera un metodo che consente di associare contemporaneamente tutti i membri di tale interfaccia.

Si consideri, ad esempio, 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 metodo Bind:

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

Modifica comportamento predefinito

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

Per impostazione predefinita, se non è stato impostato alcun comportamento specifico, 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 InstanceBehavior proprietà per qualsiasi istanza shim. Ad esempio, il frammento di codice seguente modifica il comportamento in modo da non eseguire alcuna operazione o restituire il valore predefinito del tipo restituito, default(T)ad esempio :

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

È anche possibile modificare a livello globale il comportamento per tutte le istanze di shim, in cui la InstanceBehavior proprietà non è stata definita in modo esplicito, impostando la proprietà statica ShimBehaviors.Current :

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

Identificazione delle 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. Sono inclusi i metodi statici. Impostando il ShimBehaviors.NotImplemented comportamento sulla proprietà statica Behavior del tipo shim, qualsiasi accesso a un membro di quel tipo che non è stato sottoposto esplicitamente a shim genererà un'eccezione NotImplementedException. Questo 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 questa opzione 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();

Richiamo dei metodi originali dai metodi shim

Potrebbero essere presenti scenari in cui 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 ShimsContext.ExecuteWithoutShims(), come illustrato nel codice seguente:

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

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

In alternativa, è possibile annullare 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;

Gestione della concorrenza con tipi shim

I tipi shim operano in tutti i thread all'interno di AppDomain e non possiedono l'affinità di thread. Questa proprietà è fondamentale da tenere presente se si prevede di usare un runner di test che supporta la concorrenza. Vale la pena notare che i test che coinvolgono tipi shim non possono essere eseguiti simultaneamente, anche se questa restrizione non viene applicata dal runtime Fakes.

Shimming System.Environment

Se vuoi eseguire lo shim della System.Environment classe, dovrai apportare alcune modifiche al mscorlib.fakes file. Dopo l'elemento Assembly aggiungere il contenuto seguente:

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

Dopo aver apportato queste modifiche e ricompilato la soluzione, i metodi e le proprietà nella System.Environment classe sono ora disponibili per essere sottoposti a shim. Ecco un esempio di come assegnare un comportamento al GetCommandLineArgsGet metodo :

System.Fakes.ShimEnvironment.GetCommandLineArgsGet = ...

Apportando queste modifiche, è stata aperta la possibilità di controllare e testare il modo in cui il codice interagisce con le variabili di ambiente di sistema, uno strumento essenziale per unit test completi.